iBetter Books
수정

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('할 일이 없습니다.'),
    );
  }
}

AppBaractions는 오른쪽에 배치되는 버튼 목록입니다. 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는 노치나 홈 인디케이터 등 디바이스의 안전 영역을 자동으로 피해줍니다. FormGlobalKey를 사용하면 validate() 메서드로 모든 TextFormField의 유효성 검사를 한 번에 실행할 수 있습니다. dispose()에서 TextEditingController를 반드시 해제해야 메모리 누수를 막을 수 있습니다.

이번 챕터 정리

  • Scaffold, AppBar, FloatingActionButton으로 화면의 기본 구조를 만들었습니다.
  • Column, Row, Container, SizedBox로 레이아웃을 구성했습니다.
  • ListView.separated, Card, ListTile로 할 일 목록 카드를 만들었습니다.
  • Form, TextFormField, TextEditingController로 로그인 폼을 구성했습니다.

다음 챕터에서는 StatefulWidgetProvider로 상태를 관리합니다.