Ch 02. 기본 위젯과 레이아웃
Flutter에서 모든 것은 위젯(Widget)입니다. 텍스트도 위젯, 버튼도 위젯, 여백도 위젯, 레이아웃도 위젯입니다. 처음에는 낯설지만 익숙해지면 레고 블록처럼 조립하는 감각이 생깁니다.
이번 챕터에서는 todo_app의 기본 UI를 위젯으로 구성합니다.
Scaffold — 화면의 뼈대
모바일 앱의 전형적인 구조(상단 앱바, 본문, 하단 버튼)를 Scaffold가 제공합니다.
// 새 파일: lib/screens/todo_screen.dart
import 'package:flutter/material.dart';
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('할 일 목록'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
// 로그아웃 처리 (Ch 05에서 구현)
},
),
],
),
body: const _TodoBody(),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 할 일 추가 (Ch 05에서 구현)
},
child: const Icon(Icons.add),
),
);
}
}
class _TodoBody extends StatelessWidget {
const _TodoBody();
@override
Widget build(BuildContext context) {
return const Center(
child: Text('할 일이 없습니다.'),
);
}
}
AppBar의 actions는 오른쪽에 배치되는 버튼 목록입니다. FloatingActionButton은 화면 오른쪽 하단에 떠 있는 원형 버튼입니다.
Text와 TextStyle
텍스트를 표시하는 가장 기본적인 위젯입니다.
// 텍스트 스타일 예제 (위젯 안에서 사용)
Text('할 일 없음')
Text(
'할 일이 없습니다.',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
textAlign: TextAlign.center,
)
// 테마 스타일 활용
Text(
'제목',
style: Theme.of(context).textTheme.headlineMedium,
)
하드코딩된 색상이나 크기보다 Theme.of(context).textTheme을 활용하면 앱 전체 테마에 일관성을 유지할 수 있습니다.
Column과 Row — 세로/가로 배치
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('첫 번째'),
SizedBox(height: 8), // 여백
Text('두 번째'),
SizedBox(height: 8),
Text('세 번째'),
],
)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('왼쪽'),
Icon(Icons.check),
Text('오른쪽'),
],
)
mainAxisAlignment는 주축(Column이면 세로, Row이면 가로) 정렬을 제어합니다. crossAxisAlignment는 교차축 정렬입니다. SizedBox는 고정 크기 여백을 만들 때 씁니다.
Container — 박스 모델
Container는 크기, 여백, 배경색, 테두리, 모서리 등을 설정할 수 있는 박스입니다.
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text('할 일 항목'),
)
ListView — 스크롤 목록
할 일 목록을 보여줄 때 ListView를 사용합니다.
// 새 파일: lib/widgets/todo_list_view.dart
import 'package:flutter/material.dart';
import '../models/todo.dart';
class TodoListView extends StatelessWidget {
final List<Todo> todos;
final void Function(Todo todo) onToggle;
final void Function(Todo todo) onDelete;
const TodoListView({
super.key,
required this.todos,
required this.onToggle,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
if (todos.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'할 일이 없습니다.',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: todos.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final todo = todos[index];
return _TodoCard(
todo: todo,
onToggle: () => onToggle(todo),
onDelete: () => onDelete(todo),
);
},
);
}
}
class _TodoCard extends StatelessWidget {
final Todo todo;
final VoidCallback onToggle;
final VoidCallback onDelete;
const _TodoCard({
required this.todo,
required this.onToggle,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) => onToggle(),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed
? TextDecoration.lineThrough
: TextDecoration.none,
color: todo.completed ? Colors.grey : null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
color: Colors.red.shade300,
),
),
);
}
}
ListView.separated는 항목 사이에 구분자를 자동으로 추가합니다. Card는 머티리얼 디자인의 카드 위젯으로, 그림자와 모서리 처리가 내장되어 있습니다. ListTile은 왼쪽(leading), 중앙(title, subtitle), 오른쪽(trailing) 구조를 가진 목록 항목 위젯입니다.
로그인 화면 레이아웃
// 새 파일: lib/screens/login_screen.dart
import 'package:flutter/material.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.check_circle,
size: 72,
color: Color(0xFF6750A4),
),
const SizedBox(height: 32),
Text(
'Todo App',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: '아이디',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) return '아이디를 입력하세요.';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '비밀번호',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) return '비밀번호를 입력하세요.';
return null;
},
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// 로그인 처리 (Ch 05에서 구현)
}
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('로그인', style: TextStyle(fontSize: 16)),
),
),
],
),
),
),
),
);
}
}
SafeArea는 노치나 홈 인디케이터 등 디바이스의 안전 영역을 자동으로 피해줍니다. Form과 GlobalKey를 사용하면 validate() 메서드로 모든 TextFormField의 유효성 검사를 한 번에 실행할 수 있습니다. dispose()에서 TextEditingController를 반드시 해제해야 메모리 누수를 막을 수 있습니다.
이번 챕터 정리
Scaffold,AppBar,FloatingActionButton으로 화면의 기본 구조를 만들었습니다.Column,Row,Container,SizedBox로 레이아웃을 구성했습니다.ListView.separated,Card,ListTile로 할 일 목록 카드를 만들었습니다.Form,TextFormField,TextEditingController로 로그인 폼을 구성했습니다.
다음 챕터에서는 StatefulWidget과 Provider로 상태를 관리합니다.