위젯으로 시작하는 Flutter UI
위젯으로 시작하는 Flutter UI
Flutter에서 화면을 만든다는 건 곧 위젯을 쌓는 일이에요. React에서 아이디어를 얻은 이 프레임워크는, 위젯이 자신의 설정과 상태에 따라 화면을 어떻게 그릴지 스스로 '설명'하고, 상태가 바뀌면 그 설명을 다시 그려 프레임워크가 이전 그림과 비교해서 최소한의 변화만 적용하는 구조로 돌아가요. 그래서 플러터를 처음 접할 때 가장 먼저 익숙해져야 할 말이 '모든 것이 위젯'이라는 것이죠.
가장 작은 Flutter 앱
플러터에서 가장 단순한 앱은 runApp() 함수에 위젯 하나를 넘기는 것부터 시작해요. runApp()은 전달받은 위젯을 위젯 트리의 루트로 만들고, 프레임워크가 이 루트 위젯이 화면 전체를 덮도록 강제해요.
import 'package:flutter/material.dart';
void main() {
runApp(
const Center(
child: Text(
'Hello, world!',
textDirection: TextDirection.ltr,
style: TextStyle(color: Colors.blue),
),
),
);
}
이 예시의 위젯 트리는 두 개의 위젯으로 이뤄져요. 가운데 정렬을 담당하는 Center와 그 자식인 Text죠. 여기서 textDirection을 직접 지정해야 하는 이유는 MaterialApp을 쓰기 전이라 글 방향이 정해지지 않았기 때문이에요. 나중에 MaterialApp을 쓰면 이 부분은 알아서 처리돼요.
앱을 만들다 보면 대부분 직접 만드는 위젯은 StatelessWidget이나 StatefulWidget의 서브클래스가 돼요. 어떤 위젯이냐는 상태를 관리하느냐에 따라 갈리죠. 위젯의 핵심 일은 build() 함수를 구현하는 건데, 이 함수가 위젯을 더 낮은 수준의 위젯들로 설명해요. 프레임워크는 이 과정을 반복해 결국 RenderObject라는, 위젯의 기하학을 계산·표현하는 단계까지 내려가요.
자주 쓰는 기본 위젯
플러터는 풍부한 기본 위젯을 제공하는데, 그중 특히 자주 쓰는 것들을 몇 가지 훑어볼게요.
Text— 앱 안에서 스타일이 입혀진 텍스트를 만들어요.Row,Column— 가로(Row)·세로(Column) 방향으로 자식 위젯을 유연하게 배치하는 위젯이에요. 웹의 flexbox 레이아웃 모델에서 아이디어를 가져왔어요.Stack— 선형으로 늘어놓는 대신, 위젯들을 겹쳐서 그리는 순서대로 쌓아요. 자식에Positioned를 쓰면 스택의 상·하·좌·우 가장자리에 상대적으로 배치할 수 있어요. 웹의 절대 위치(absolute positioning) 레이아웃 모델을 기반으로 해요.Container— 사각형 시각 요소를 만드는 위젯이에요.BoxDecoration으로 배경·테두리·그림자를 입힐 수 있고, 마진·패딩·크기 제약도 걸 수 있어요. 행렬로 3D 변환까지 가능하죠.
이런 위젯들을 조합해 만든 예시를 하나 볼게요. 아래 코드는 상단에 앱 바(app bar)를 두고, 그 아래 본문을 배치하는 구조예요.
import 'package:flutter/material.dart';
class MyAppBar extends StatelessWidget {
const MyAppBar({required this.title, super.key});
// Widget 서브클래스의 필드는 항상 final로 표시해요.
final Widget title;
@override
Widget build(BuildContext context) {
return Container(
height: 56, // 논리 픽셀 단위
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(color: Colors.blue[500]),
// Row는 가로 방향의 선형 레이아웃이에요.
child: Row(
children: [
const IconButton(
icon: Icon(Icons.menu),
tooltip: 'Navigation menu',
onPressed: null, // null이면 버튼이 비활성화돼요.
),
// Expanded는 자식을 남은 공간만큼 채워요.
Expanded(child: title),
const IconButton(
icon: Icon(Icons.search),
tooltip: 'Search',
onPressed: null,
),
],
),
);
}
}
class MyScaffold extends StatelessWidget {
const MyScaffold({super.key});
@override
Widget build(BuildContext context) {
return Material(
child: Column(
children: [
MyAppBar(
title: Text(
'Example title',
style: Theme.of(context).primaryTextTheme.titleLarge,
),
),
const Expanded(child: Center(child: Text('Hello, world!'))),
],
),
);
}
}
void main() {
runApp(
const MaterialApp(
title: 'My app', // OS 작업 전환기에 표시돼요.
home: SafeArea(child: MyScaffold()),
),
);
}
MyAppBar는 높이 56, 좌우 패딩 8의 Container를 만들고 그 안에서 Row로 자식들을 정리해요. 가운데 title 위젯은 Expanded로 표시돼서 나머지 공간을 꽉 채우죠. Expanded는 여러 개를 쓸 수 있고, flex 인자로 공간을 나누는 비율을 조절할 수 있어요.
MyScaffold는 자식을 세로 Column으로 정렬하는데, 맨 위에 MyAppBar를 두고 제목으로 쓸 Text 위젯을 넘겨요. 위젯을 다른 위젯의 인자로 넘기는 이 패턴이 플러터의 강력한 기법 중 하나예요. 이렇게 하면 다양한 상황에서 재사용할 수 있는 제네릭한 위젯을 만들 수 있죠. 마지막으로 Expanded로 나머지 공간을 본문(가운데 정렬된 메시지)으로 채워요.
Material 아이콘을 쓰려면 pubspec.yaml의 flutter 섹션에 uses-material-design: true를 넣어야 해요.
name: my_app
flutter:
uses-material-design: true
Material 위젯 대부분은 테마 데이터를 물려받기 위해 MaterialApp 안에 있어야 제대로 표시돼요. 그래서 앱을 MaterialApp으로 실행하는 편을 권장해요.
제스처 처리하기
사용자 상호작용의 첫 단계는 입력 제스처를 감지하는 거예요. GestureDetector로 간단한 버튼을 만들 수 있어요.
import 'package:flutter/material.dart';
class MyButton extends StatelessWidget {
const MyButton({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
print('MyButton was tapped!');
},
child: Container(
height: 50,
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Colors.lightGreen[500],
),
child: const Center(child: Text('Engage')),
),
);
}
}
여기서 잠깐 짚고 갈 점이 있어요. GestureDetector로 만든 위젯은 build() 함수가 호출될 때마다 새로 만들어지는 탭 콜백이에요. 두 번 빌드되면 서로 다른 클로저(closure)가 생기죠. 상태를 관리하는 StatefulWidget이라면 initState()에서 콜백을 만들어 필드로 저장해서 두 빌드가 같은 콜백을 공유하게 만들어야 해요. 상태가 있는 위젯이라 이 부분이 헷갈리기 쉬우니 꼭 기억해 두세요.
상태 있는 위젯: StatefulWidget과 키
화면이 사용자 입력에 반응하거나 시간에 따라 변하려면 상태를 가져야 해요. StatefulWidget은 스스로는 불변이지만, 분리된 State 객체를 만들어 그 객체가 수명 동안 변하는 상태를 들고 있어요.
StatefulWidget의 createState()를 호출하면 프레임워크가 새 상태 객체를 트리에 넣고 initState()를 부르죠. initState는 딱 한 번만 하면 되는 작업(애니메이션 설정, 플랫폼 서비스 구독 등)을 할 때 오버라이드해요. 반대로 상태 객체가 더 필요 없어지면 dispose()가 불리고, 타이머 취소나 서비스 구독 해제 같은 정리 작업을 여기서 해요. initState는 super.initState로, dispose는 super.dispose로 시작·끝내는 규칙이 있어요.
위젯이 다시 빌드될 때 프레임워크가 어떤 위젯끼리 짝지을지 정할 때 키(key) 를 써요. 기본적으로 프레임워크는 runtimeType과 등장 순서로 짝지지만, 키를 달면 runtimeType과 key가 모두 같아야 짝지어져요. 같은 타입 위젯을 여러 개 만드는 화면(예: 스크롤되는 리스트)에서는 키가 아주 유용해요. 리스트 항목마다 의미 있는 키를 달면, 뷰포트 밖으로 나간 항목이 순서가 아니라 의미로 짝지어져 상태 유지가 정확해져요.
전체 위젯 계층에서 유일하게 식별해야 할 때는 전역 키(GlobalKey) 를 써요. 전역 키는 형제끼리만 유일하면 되는 지역 키와 달리 계층 전체에서 유일해야 하고, 이 덕분에 위젯에 연결된 상태를 꺼내 쓸 수도 있어요.
더 알아보기
- 레이아웃: Layouts
- Material 컴포넌트 위젯: Material Components widgets
StateAPI: StateKeyAPI: Key, GlobalKey