위젯을 예쁘게 배치하는 것과 앱을 디자인하는 것은 다르다
지난 장까지 우리는 Container, Row, Column, Stack, ListView 같은 레이아웃 위젯으로 화면을 배치하는 법을 배웠다. 이제 화면에는 무언가가 놓일 수 있다. 하지만 실제 앱을 열어 보면 단순히 위젯이 놓여 있다고 해서 "앱답다"는 느낌이 들지는 않는다. 버튼마다 색이 다르고, 카드마다 둥근 정도가 다르고, 화면마다 글자 크기가 제각각이라면 기능은 동작해도 제품처럼 보이지 않는다.
Flutter에서 앱다운 UI를 만드는 핵심은 개별 위젯을 하나씩 예쁘게 꾸미는 것이 아니라, 앱 전체가 따르는 디자인 규칙을 먼저 정하는 것이다. 이 규칙을 Flutter에서는 주로 ThemeData로 관리한다. ThemeData는 앱의 색상, 글꼴, 컴포넌트 모양, 여백감, 버튼 스타일 같은 시각적 결정을 한곳에 모아 둔 설정 객체다. 공식 문서에서도 MaterialApp의 theme 속성을 통해 앱 전체의 시각 테마를 구성하고, 필요하면 Theme 위젯으로 특정 하위 트리만 재정의할 수 있다고 설명한다.1
이 장에서는 Flutter의 기본 디자인 언어인 Material 3를 기준으로 앱 전체 테마를 만들고, Scaffold, AppBar, NavigationBar, Card, Button, TextField, Dialog, BottomSheet, SnackBar 같은 주요 컴포넌트를 일관되게 사용하는 법을 배운다. 목표는 단순하다. 화면을 하나 만드는 것이 아니라, 앞으로 만들 모든 화면이 같은 앱처럼 보이게 만드는 것이다.

Material 3는 Flutter 앱의 기본 언어다
Material Design은 Google이 만든 오픈소스 디자인 시스템이다. 여기서 디자인 시스템이란 버튼 모양, 색상 사용법, 글자 계층, 모션, 접근성, 큰 화면 대응 같은 UI 규칙의 모음이라고 이해하면 된다. 최신 버전인 Material 3는 동적 색상, 향상된 접근성, 큰 화면 레이아웃 기반, 디자인 토큰을 포함하는 더 현대적인 디자인 체계다.2
Flutter는 Material 컴포넌트를 SDK에 함께 제공한다. 특히 Flutter 3.16에서 Material 3로의 기본 전환이 완료되었고, 현재 Flutter의 Material 위젯들은 Material 3 사양을 구현하는 시각적·동작적·모션 중심 위젯으로 설명된다.3 예전 예제에서 자주 보던 primarySwatch, BottomNavigationBar, Material 2 스타일 버튼만 기억하고 있다면, 이제는 ColorScheme.fromSeed, NavigationBar, FilledButton, surface 계열 색상을 기준으로 생각하는 편이 좋다.
Material 3를 쓴다는 것은 "버튼이 조금 둥글어진다" 정도의 변화가 아니다. 앱 전체가 다음과 같은 원칙을 갖게 된다는 뜻이다.
색상을 직접 찍어 바르기보다
primary,secondary,surface,error같은 역할 기반 색상을 사용한다.밝은 테마와 어두운 테마가 같은 브랜드 감각을 유지하면서도 읽기 좋은 대비를 갖는다.
버튼, 카드, 앱 바, 내비게이션 바가 각자 제멋대로 꾸며지는 것이 아니라 중앙 테마 설정을 따른다.
접근성, 큰 화면, 동적 색상 같은 확장 가능성을 처음부터 고려한다.
입문 단계에서는 이 말이 조금 거창하게 들릴 수 있다. 하지만 실제 코드는 생각보다 간단하다. 먼저 앱 전체의 색상 씨앗, 즉 seedColor 하나를 정하고, Flutter가 그 색에서 조화로운 색상 체계를 만들도록 맡기면 된다.
MaterialApp은 앱 전체 디자인의 입구다
Flutter 앱의 최상단에는 보통 MaterialApp이 있다. MaterialApp은 라우팅, 지역화, 디버그 배너 설정 같은 역할도 하지만, UI 관점에서는 앱 전체 테마를 넣는 입구다.
가장 기본적인 구조는 다음과 같다.
import 'package:flutter/material.dart';
void main() {
runApp(const NoteApp());
}
class NoteApp extends StatelessWidget {
const NoteApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Knowledge Note',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
home: const HomeShell(),
);
}
}여기서 중요한 속성은 theme, darkTheme, themeMode다.
theme은 밝은 테마다. 사용자의 기기가 라이트 모드이거나 앱이 강제로 밝은 테마를 사용할 때 적용된다. darkTheme은 어두운 테마다. themeMode는 둘 중 어떤 테마를 사용할지 결정한다. ThemeMode.system으로 두면 기기 설정을 따른다. 사용자가 밤에는 다크 모드로, 낮에는 라이트 모드로 쓰고 있다면 앱도 자연스럽게 따라간다.
themeMode에는 보통 세 가지 선택지가 있다.
ThemeMode.system: 사용자의 시스템 설정을 따른다.ThemeMode.light: 항상 밝은 테마를 사용한다.ThemeMode.dark: 항상 어두운 테마를 사용한다.
실무 앱에서는 사용자의 선택을 저장해 두고 themeMode를 바꾸기도 한다. 예를 들어 설정 화면에서 "시스템 설정 따르기 / 밝게 / 어둡게"를 고르게 만들 수 있다. 이 기능은 나중에 상태관리와 로컬 저장소를 배운 뒤 자연스럽게 구현할 수 있다. 지금은 MaterialApp이 앱 전체 테마의 출발점이라는 사실에 집중하자.
Scaffold, AppBar, NavigationBar로 화면의 뼈대를 세운다
앱의 각 화면은 보통 Scaffold로 시작한다. Scaffold는 말 그대로 비계를 세우듯 화면의 기본 구조를 만들어 준다. 위쪽에는 AppBar, 가운데에는 body, 아래쪽에는 NavigationBar, 오른쪽 아래에는 FloatingActionButton을 배치할 수 있다.
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('지식 노트'),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.search),
tooltip: '검색',
),
],
),
body: IndexedStack(
index: selectedIndex,
children: const [
NotesHomeScreen(),
TagsScreen(),
SettingsScreen(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
setState(() {
selectedIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.sticky_note_2_outlined),
selectedIcon: Icon(Icons.sticky_note_2),
label: '노트',
),
NavigationDestination(
icon: Icon(Icons.sell_outlined),
selectedIcon: Icon(Icons.sell),
label: '태그',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: '설정',
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('새 노트'),
),
);
}
}이 코드는 앞으로 이 책의 최종 프로젝트인 개인 지식 노트 앱을 떠올리며 만든 기본 골격이다. 위에는 앱 제목과 검색 버튼이 있고, 아래에는 노트·태그·설정으로 이동하는 내비게이션 바가 있으며, 오른쪽 아래에는 새 노트를 만드는 강조 버튼이 있다.
여기서 눈여겨볼 부분은 색상을 거의 직접 지정하지 않았다는 점이다. AppBar, NavigationBar, FloatingActionButton은 현재 앱의 ThemeData를 읽고 알아서 어울리는 색을 사용한다. 이것이 테마의 힘이다. 매 화면마다 Colors.blue, Colors.white, Colors.black87을 반복해서 넣지 않아도 된다.
ColorScheme: 색을 고르는 것이 아니라 역할을 정한다
Material 3 테마의 중심에는 ColorScheme이 있다. ColorScheme은 앱에서 사용하는 색을 "파란색", "회색", "빨간색"처럼 이름 붙이지 않고, "주요 행동 색", "표면 색", "오류 색", "그 위에 올라가는 글자 색"처럼 역할로 나눈다. Flutter의 ColorScheme은 Material 사양에 기반한 여러 색상 역할을 제공하며, Material 3에서는 primary, secondary, tertiary, surface, error 같은 역할과 그 위에 그려질 onPrimary, onSurface 같은 색상을 함께 사용한다.4
가장 쉬운 시작 방법은 ColorScheme.fromSeed다.
final lightColorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF3F6DF6),
brightness: Brightness.light,
);
final darkColorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF3F6DF6),
brightness: Brightness.dark,
);seedColor는 브랜드의 대표 색상이다. 개인 지식 노트 앱이라면 차분하고 집중감 있는 파란색을 쓸 수 있고, 운동 기록 앱이라면 에너지 있는 초록색이나 주황색을 쓸 수 있다. ColorScheme.fromSeed는 이 색을 바탕으로 밝은 테마와 어두운 테마에 필요한 색상 묶음을 생성한다.
초보자가 자주 하는 실수는 모든 곳에 primary만 사용하는 것이다. 강조하고 싶은 요소가 보이면 primary, 카드 배경에도 primary, 텍스트에도 primary, 아이콘에도 primary를 넣는다. 그러면 화면은 금방 시끄러워진다. Material 3에서는 색을 다음처럼 나눠 생각하는 편이 좋다.
primary: 가장 중요한 행동이나 현재 선택 상태에 사용한다.secondary: 보조적인 강조, 필터, 덜 중요한 액션에 사용한다.tertiary: 추가적인 포인트 색이나 특별한 정보 강조에 사용한다.surface: 화면 배경, 카드, 시트, 다이얼로그처럼 넓은 표면에 사용한다.error: 오류, 삭제, 실패 상태처럼 주의가 필요한 상황에 사용한다.onPrimary,onSurface,onError: 각각의 배경색 위에 올라가는 글자와 아이콘에 사용한다.
예를 들어 primary 배경 위에 글자를 올린다면 글자 색은 직접 Colors.white로 정하기보다 onPrimary를 쓰는 것이 안전하다.
final scheme = Theme.of(context).colorScheme;
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'오늘의 핵심 노트',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: scheme.onPrimary,
),
),
)on이 붙은 색은 "그 색 위에 올라가는 색"이라고 기억하면 쉽다. onPrimary는 primary 위에, onSurface는 surface 위에, onError는 error 위에 올라간다. 이 짝을 지켜 주면 밝은 모드와 어두운 모드에서도 글자가 묻히는 일을 줄일 수 있다. Material 3 색상 체계는 이런 접근성 대비 관계를 중요하게 다룬다.5
앱 테마 파일 만들기
이제 앱 전체 테마를 별도 파일로 분리해 보자. 작은 앱에서는 main.dart 안에 테마를 써도 되지만, 앱이 커지면 금방 복잡해진다. lib/app_theme.dart 또는 lib/core/theme/app_theme.dart처럼 테마 전용 파일을 만드는 습관을 들이면 좋다.
import 'package:flutter/material.dart';
class AppTheme {
static const Color seedColor = Color(0xFF3F6DF6);
static ThemeData get light {
return _buildTheme(Brightness.light);
}
static ThemeData get dark {
return _buildTheme(Brightness.dark);
}
static ThemeData _buildTheme(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
surfaceTintColor: Colors.transparent,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: colorScheme.surface,
indicatorColor: colorScheme.secondaryContainer,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(WidgetState.selected);
return TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
);
}),
),
cardTheme: CardThemeData(
elevation: 0,
color: colorScheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 0,
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primaryContainer,
foregroundColor: colorScheme.onPrimaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.primary,
width: 1.5,
),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: colorScheme.inverseSurface,
contentTextStyle: TextStyle(
color: colorScheme.onInverseSurface,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}이 코드에서 핵심은 ThemeData 안에 컴포넌트별 테마를 넣었다는 점이다. AppBarTheme, NavigationBarThemeData, CardThemeData, FilledButtonThemeData, ElevatedButtonThemeData, FloatingActionButtonThemeData, InputDecorationTheme, SnackBarThemeData가 모두 앱의 디자인 시스템 일부가 된다.
이렇게 해 두면 화면을 만들 때 다음과 같은 코드를 반복하지 않아도 된다.
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: () {},
child: const Text('저장'),
)대신 화면에서는 의도만 표현하면 된다.
FilledButton(
onPressed: () {},
child: const Text('저장'),
)이 차이는 앱이 커질수록 엄청나게 커진다. 버튼 모서리를 14에서 16으로 바꾸고 싶을 때 모든 화면을 뒤질 필요가 없다. 테마 파일 한 곳만 바꾸면 된다. 이것이 "앱다운 UI"의 출발점이다.
surface 색상과 최신 ColorScheme 역할 이해하기
예전 Flutter 예제에는 background, onBackground, surfaceVariant 같은 색상 역할이 자주 등장한다. 하지만 Material 3의 최신 흐름에서는 background 대신 surface, onBackground 대신 onSurface, surfaceVariant 대신 surfaceContainerHighest를 사용하는 방향으로 바뀌었다. Flutter 공식 변경 문서도 Material 3에 맞춰 톤 기반 surface 색상과 컨테이너 역할이 추가되었고, 기존 세 역할은 대체 사용을 권장한다고 설명한다.6
즉 새 코드를 작성할 때는 다음처럼 생각하면 된다.
final scheme = Theme.of(context).colorScheme;
// 넓은 화면 배경
final pageBackground = scheme.surface;
// 카드나 입력창처럼 배경보다 살짝 떠 있는 영역
final cardBackground = scheme.surfaceContainerLow;
final inputBackground = scheme.surfaceContainerHighest;
// 일반 텍스트와 아이콘
final normalText = scheme.onSurface;surfaceContainerLow, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest는 모두 표면 색상 계열이다. 이름이 길어서 처음에는 부담스럽지만, 의미는 단순하다. 화면 배경 위에 놓이는 요소의 층을 조금씩 구분하는 색이다. 카드, 입력창, 바텀시트, 다이얼로그처럼 "바닥 위에 놓인 종이"를 표현할 때 유용하다.
앱이 다크 모드로 전환되면 이 차이는 더 중요해진다. 어두운 화면에서 모든 배경을 같은 검은색으로 칠하면 카드와 배경의 경계가 사라진다. 반대로 흰색 선을 너무 많이 넣으면 피곤해 보인다. Material 3의 surface 계열 색상을 사용하면 어두운 화면에서도 자연스러운 층을 만들 수 있다.
TextTheme: 글자에도 계층이 필요하다
앱 화면에서 색상만큼 중요한 것이 글자다. 모든 텍스트가 같은 크기와 굵기를 가지면 사용자는 어디를 먼저 봐야 할지 모른다. Flutter의 TextTheme은 제목, 본문, 라벨 같은 텍스트 계층을 제공한다. 공식 문서에서도 ThemeData에서 앱 전체 색상은 colorScheme, 글자 스타일은 textTheme이 주로 담당한다고 설명한다.7
Material 3의 텍스트 스타일은 대략 다음처럼 사용할 수 있다.
class NoteSummaryCard extends StatelessWidget {
const NoteSummaryCard({super.key});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Flutter 상태관리 정리',
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
'Provider, Riverpod, Bloc의 차이를 예제 중심으로 비교한 노트입니다.',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Text(
'3분 전 수정됨',
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}여기서 titleLarge는 카드 제목, bodyMedium은 설명, labelSmall은 부가 정보에 사용했다. 중요한 점은 글자 크기를 매번 fontSize: 17처럼 직접 넣지 않았다는 것이다. 테마가 제공하는 계층을 사용하고, 필요한 경우 copyWith로 굵기나 색상만 살짝 조정했다.
글자 색상도 마찬가지다. 일반 본문은 대개 onSurface가 어울리고, 덜 중요한 설명은 onSurfaceVariant가 어울린다. 단, Flutter 최신 문서에서는 surfaceVariant 대신 surfaceContainerHighest로의 전환을 권장하지만, onSurfaceVariant는 여전히 보조 텍스트에 자주 쓰이는 역할로 볼 수 있다. 중요한 것은 직접 회색 값을 외우는 것이 아니라, 테마가 제공하는 의미 있는 색상 역할을 사용하는 습관이다.
주요 컴포넌트는 용도에 맞게 고른다
Flutter에는 비슷해 보이는 컴포넌트가 많다. FilledButton, ElevatedButton, TextButton, IconButton, FloatingActionButton은 모두 "누를 수 있는 것"이다. 하지만 용도가 다르다. 좋은 UI는 화려한 컴포넌트를 많이 쓰는 UI가 아니라, 사용자가 다음 행동을 헷갈리지 않는 UI다.
Material Design의 컴포넌트는 액션, 담기, 커뮤니케이션, 내비게이션, 선택, 텍스트 입력 같은 목적에 따라 분류할 수 있다.8 Flutter에서도 이 관점으로 컴포넌트를 고르면 화면이 훨씬 명확해진다.
Card: 정보를 한 덩어리로 묶기
Card는 관련 있는 정보를 하나의 묶음으로 보여줄 때 사용한다. 노트 앱에서는 노트 목록의 각 항목, 사용자 프로필 요약, 최근 활동 묶음에 어울린다.
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'오늘 배운 것',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Material 3에서는 색상을 직접 고르기보다 ColorScheme의 역할을 사용한다.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)카드를 쓸 때는 카드 안팎 여백이 중요하다. 카드 자체가 정보를 묶는 컨테이너이므로 내부에는 충분한 Padding을 주고, 카드 사이에는 SizedBox나 리스트의 separatorBuilder로 일정한 간격을 둔다.
FilledButton: 가장 중요한 행동
FilledButton은 화면에서 가장 중요한 행동에 사용한다. 저장, 다음, 로그인, 구매처럼 사용자가 지금 해야 할 핵심 액션에 어울린다.
FilledButton(
onPressed: () {},
child: const Text('노트 저장'),
)한 화면에 FilledButton이 너무 많으면 무엇이 중요한지 알 수 없다. 보통 한 화면 또는 한 섹션에서 가장 중요한 버튼 하나에만 사용하는 편이 좋다.
ElevatedButton: 살짝 강조된 보조 행동
ElevatedButton은 표면 위에 떠 있는 느낌을 줄 수 있는 버튼이다. Material 3에서는 예전보다 그림자 사용이 절제되어 있지만, 여전히 보조적으로 강조하고 싶은 행동에 쓸 수 있다.
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.upload_file),
label: const Text('파일 가져오기'),
)다만 FilledButton과 ElevatedButton을 아무 기준 없이 섞으면 UI가 산만해진다. 이 책에서는 주요 행동은 FilledButton, 보조 행동은 ElevatedButton 또는 OutlinedButton, 덜 중요한 행동은 TextButton으로 나누는 방식을 추천한다.
IconButton: 공간을 아끼는 아이콘 행동
IconButton은 검색, 삭제, 닫기, 공유처럼 아이콘만으로 의미가 비교적 명확한 행동에 사용한다.
IconButton(
onPressed: () {},
icon: const Icon(Icons.delete_outline),
tooltip: '삭제',
)tooltip을 넣어 두면 사용자가 길게 눌렀을 때 설명을 볼 수 있고, 접근성 도구에서도 의미를 전달하기 좋다. 아이콘만 있다고 모든 사용자가 뜻을 알아차리는 것은 아니다. 특히 삭제, 보관, 공유처럼 실수하면 곤란한 행동에는 더 친절한 설명이 필요하다.
FloatingActionButton: 화면의 대표 행동
FloatingActionButton, 줄여서 FAB는 화면의 대표 행동을 강조한다. 노트 목록 화면에서는 "새 노트", 메일 앱에서는 "메일 작성", 지도 앱에서는 "현재 위치" 같은 행동이 여기에 해당한다.
FloatingActionButton.extended(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('새 노트'),
)FAB는 눈에 잘 띄기 때문에 남용하면 안 된다. 화면 오른쪽 아래에서 계속 떠 있는 만큼, 사용자가 "이 화면에서 가장 자연스럽게 할 일"에만 쓰는 편이 좋다.
TextField: 입력을 받는 입구
TextField는 사용자 입력의 시작점이다. 검색어, 제목, 메모, 이메일, 비밀번호 등을 받을 때 사용한다. 입력 화면 전체는 다음 장에서 자세히 다루겠지만, 여기서는 테마와 연결되는 부분만 보자.
TextField(
decoration: const InputDecoration(
labelText: '노트 제목',
hintText: '예: Flutter 테마 정리',
prefixIcon: Icon(Icons.title),
),
)앞에서 inputDecorationTheme을 설정했기 때문에 모든 TextField는 기본적으로 같은 모서리, 같은 배경, 같은 포커스 색상을 가진다. 화면마다 입력창 모양을 새로 지정하지 않아도 된다.
Dialog: 중요한 확인과 결정
Dialog는 사용자의 흐름을 잠시 멈추고 중요한 결정을 요구할 때 사용한다. 삭제 확인, 로그아웃 확인, 권한 설명처럼 그냥 지나치면 안 되는 상황에 적합하다.
Future<void> showDeleteDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('노트를 삭제할까요?'),
content: const Text('삭제한 노트는 복구할 수 없습니다.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('삭제'),
),
],
);
},
);
if (confirmed == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('노트를 삭제했습니다.')),
);
}
}다이얼로그는 강력한 도구다. 강력하다는 말은 자주 쓰면 피곤해진다는 뜻이기도 하다. 사용자가 반드시 확인해야 하는 일에만 사용하자.
BottomSheet: 보조 흐름 열기
BottomSheet는 현재 화면을 완전히 떠나지 않고 보조적인 선택이나 작업을 보여줄 때 좋다. 정렬 옵션, 필터, 공유 메뉴, 간단한 작성 폼에 어울린다.
void showSortSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'정렬 기준',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
ListTile(
leading: const Icon(Icons.schedule),
title: const Text('최근 수정순'),
onTap: () => Navigator.of(context).pop(),
),
ListTile(
leading: const Icon(Icons.sort_by_alpha),
title: const Text('제목순'),
onTap: () => Navigator.of(context).pop(),
),
],
),
),
);
},
);
}showDragHandle: true를 사용하면 사용자는 이 시트가 아래에서 올라온 임시 패널이라는 것을 더 쉽게 이해한다. 작은 디테일이지만 앱의 완성도를 높여 준다.
SnackBar: 잠깐 보여주는 피드백
SnackBar는 저장 완료, 삭제 완료, 네트워크 오류 같은 짧은 피드백에 사용한다. 사용자의 흐름을 막지 않고 잠깐 알려 주는 데 적합하다.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('노트를 저장했습니다.'),
action: SnackBarAction(
label: '되돌리기',
onPressed: () {},
),
),
);SnackBar는 다이얼로그보다 가볍다. 성공 메시지처럼 사용자가 반드시 확인 버튼을 누를 필요가 없는 상황에서는 다이얼로그보다 스낵바가 더 자연스럽다.
Theme.of(context)는 현재 위치의 테마를 읽는다
위젯 안에서 현재 테마를 읽을 때는 Theme.of(context)를 사용한다.
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final textTheme = theme.textTheme;이 코드는 "현재 BuildContext에서 가장 가까운 Theme을 찾아 달라"는 뜻이다. 앱 전체에 설정한 MaterialApp.theme이 기본값이지만, 중간에 Theme 위젯으로 일부 영역의 테마를 바꾸면 그 하위 위젯들은 바뀐 테마를 읽는다. Flutter 공식 레시피도 특정 구간만 전체 테마와 다르게 만들고 싶을 때 Theme 위젯을 사용하거나, 부모 테마를 copyWith로 확장하는 방법을 소개한다.9
예를 들어 새 노트 작성 영역만 조금 더 따뜻한 색상으로 강조하고 싶다면 다음처럼 할 수 있다.
class HighlightedCreateNoteCard extends StatelessWidget {
const HighlightedCreateNoteCard({super.key});
@override
Widget build(BuildContext context) {
final parentTheme = Theme.of(context);
final warmScheme = ColorScheme.fromSeed(
seedColor: Colors.orange,
brightness: parentTheme.brightness,
);
return Theme(
data: parentTheme.copyWith(
colorScheme: warmScheme,
),
child: Builder(
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: scheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'아이디어가 떠올랐나요?',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: scheme.onPrimaryContainer,
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: () {},
child: const Text('바로 기록하기'),
),
],
),
),
);
},
),
);
}
}여기서 Builder를 사용한 이유는 새 Theme 아래의 BuildContext를 얻기 위해서다. Theme.of(context)는 현재 컨텍스트의 조상 방향으로 테마를 찾기 때문에, 같은 build 메서드 안에서 방금 만든 Theme을 즉시 읽으려면 새 컨텍스트가 필요할 수 있다. 처음에는 헷갈리지만, "테마는 위에서 아래로 흐른다"고 생각하면 이해하기 쉽다.
밝은 테마와 어두운 테마를 함께 설계한다
다크 모드는 이제 선택 기능이 아니라 기본 기대 사항에 가깝다. 밤에 침대에서 앱을 열었는데 화면이 갑자기 하얗게 번쩍이면 사용자는 앱을 조용히 닫아 버릴지도 모른다. Flutter에서는 theme, darkTheme, themeMode만 잘 구성해도 기본적인 다크 모드 전환이 가능하다.
MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
home: const HomeShell(),
)밝은 테마와 어두운 테마를 따로 만들 때도 같은 seedColor를 사용하면 브랜드 정체성을 유지하기 쉽다.
static ThemeData _buildTheme(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
);
}이 접근 방식의 장점은 직접 색상표를 모두 만들지 않아도 된다는 것이다. 라이트 모드에서는 밝은 표면과 어두운 글자, 다크 모드에서는 어두운 표면과 밝은 글자가 자연스럽게 구성된다. 물론 브랜드가 강한 앱이라면 디자이너가 만든 색상표를 직접 ColorScheme에 넣을 수도 있다. 하지만 입문 단계와 1인 개발 프로젝트에서는 fromSeed가 훌륭한 출발점이다.
다크 모드를 확인할 때는 반드시 실제 화면에서 다음을 살펴보자.
카드와 배경이 구분되는가?
본문 텍스트와 보조 텍스트가 충분히 읽히는가?
비활성 버튼과 활성 버튼이 헷갈리지 않는가?
오류 메시지나 삭제 버튼이 너무 자극적이거나 너무 흐리지 않은가?
이미지, 아이콘, 구분선이 어두운 배경에서도 자연스러운가?
테마는 코드로 끝나는 작업이 아니다. 실제 사용자가 보는 화면에서 계속 조정해야 하는 감각의 영역이기도 하다.
Material Theme Builder와 seed color
색을 고르는 일은 생각보다 어렵다. "파란색으로 하자"까지는 쉬운데, 그 파란색 위의 글자색, 카드 배경색, 다크 모드의 표면색, 오류색과의 조합까지 생각하면 갑자기 복잡해진다. 이럴 때 Material Theme Builder 같은 도구를 사용하면 브랜드 색상에서 출발해 Material 3 색상 체계를 시각적으로 확인할 수 있다. Material 공식 문서는 Flutter에서 Material 3를 디자인부터 배포까지 적용하는 자료와 테마 기법을 제공한다.10
실무에서는 보통 두 가지 방식 중 하나를 선택한다.
첫 번째는 ColorScheme.fromSeed를 사용하는 방식이다. 개발자가 seed color만 정하고 Flutter가 나머지 색상 역할을 만들어 준다. 빠르고 안정적이며, 이 책의 예제 대부분은 이 방식을 따른다.
두 번째는 디자인 도구에서 생성한 light/dark ColorScheme을 코드로 가져와 직접 정의하는 방식이다. 디자이너와 협업하거나 브랜드 가이드가 엄격한 앱에서는 이 방식이 더 적합할 수 있다.
입문자에게 추천하는 흐름은 이렇다. 먼저 fromSeed로 앱을 빠르게 완성한다. 그다음 실제 화면을 보며 브랜드 색을 조정한다. 마지막으로 앱이 어느 정도 커졌을 때 필요하면 Material Theme Builder나 디자이너가 만든 색상표를 반영한다. 처음부터 완벽한 색상 체계를 만들려고 오래 멈춰 있을 필요는 없다. 앱은 움직이는 화면에서 완성된다.
작은 예제: 노트 목록 화면을 Material 3답게 만들기
이제 지금까지 배운 내용을 한 화면에 적용해 보자. 노트 목록, 검색 입력창, 카드, 버튼, 스낵바를 함께 사용하는 예제다.
class NotesHomeScreen extends StatelessWidget {
const NotesHomeScreen({super.key});
@override
Widget build(BuildContext context) {
final notes = [
'Material 3 테마 정리',
'Riverpod 상태관리 아이디어',
'Firebase 보안 규칙 메모',
];
return ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
decoration: const InputDecoration(
hintText: '노트 검색',
prefixIcon: Icon(Icons.search),
),
onSubmitted: (value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('"$value" 검색은 곧 구현합니다.')),
);
},
),
const SizedBox(height: 20),
Text(
'최근 노트',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
for (final note in notes) ...[
NoteListCard(title: note),
const SizedBox(height: 12),
],
],
);
}
}
class NoteListCard extends StatelessWidget {
const NoteListCard({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {},
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.description_outlined,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'방금 전 수정됨',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () {},
icon: const Icon(Icons.more_vert),
tooltip: '더보기',
),
],
),
),
),
);
}
}이 화면에는 특별히 화려한 코드가 없다. 그런데도 꽤 앱답게 보일 것이다. 이유는 각 요소가 자기 역할을 지키기 때문이다. 검색은 TextField, 정보 묶음은 Card, 아이콘 행동은 IconButton, 일시적 피드백은 SnackBar, 색상은 ColorScheme, 글자는 TextTheme에서 가져온다.
좋은 UI 코드는 대개 "나는 이 버튼을 파란색으로 칠하고, 이 카드는 회색으로 칠하고…"라고 말하지 않는다. 대신 "이것은 주요 행동이고, 이것은 표면이고, 이것은 보조 텍스트다"라고 말한다. Material 3는 이런 사고방식을 코드로 옮기게 도와준다.
접근성: 예쁜 것보다 먼저 읽혀야 한다
예쁜 앱도 글자가 읽히지 않으면 실패한 앱이다. Material 3가 색상 역할과 on 색상을 강조하는 이유도 결국 접근성 때문이다. 예를 들어 primary 위에 onPrimary를 사용하면, 배경과 콘텐츠의 대비를 더 안전하게 가져갈 수 있다. ColorScheme 문서도 onPrimary 같은 on 색상이 대응하는 배경 위의 텍스트와 아이콘에 사용되며, 읽기 위해 충분한 대비가 필요하다고 설명한다.4
Flutter 앱을 만들 때 접근성을 위해 최소한 다음 습관은 지키자.
배경색을 직접 정했다면 그 위의 글자색도 함께 점검한다.
작은 글자에 너무 연한 회색을 사용하지 않는다.
아이콘 버튼에는 가능한
tooltip을 넣는다.시스템 글자 크기를 키웠을 때 화면이 터지지 않는지 확인한다.
다크 모드에서 카드, 입력창, 구분선이 충분히 구분되는지 확인한다.
접근성은 나중에 여유가 생기면 하는 추가 작업이 아니다. 처음부터 테마와 컴포넌트를 올바르게 쓰면 상당 부분 자연스럽게 따라온다.
화면마다 직접 꾸미고 싶어질 때
입문자가 테마를 배우는 동안 가장 많이 하는 질문은 이것이다. "그냥 위젯마다 style 넣으면 안 되나요?" 물론 된다. Flutter는 유연하다. 하지만 앱이 커질수록 직접 스타일 지정은 빚처럼 쌓인다.
예를 들어 앱 안에 저장 버튼이 30개 있다고 하자. 처음에는 모두 둥근 정도가 12였다. 출시 직전 디자이너가 "버튼이 조금 딱딱해 보여서 radius를 16으로 바꿔 주세요"라고 말한다. 버튼마다 직접 styleFrom을 넣었다면 30곳을 찾아 바꿔야 한다. 하나라도 놓치면 어떤 화면은 예전 스타일로 남는다. 반면 FilledButtonThemeData에 정의해 두었다면 한 곳만 바꾸면 된다.
그렇다고 모든 스타일을 무조건 테마에 넣어야 하는 것은 아니다. 기준은 간단하다. 앱 전체에서 반복되는 규칙이면 테마로 올리고, 특정 화면에서만 의미 있는 예외라면 해당 위젯에 직접 지정한다. 테마는 감옥이 아니라 기본값이다. 기본값이 좋아야 예외도 아름답다.
정리: 디자인 시스템을 가진 앱으로 한 단계 올라가기
이번 장에서는 Material 3를 기준으로 Flutter 앱의 디자인 시스템을 만드는 방법을 배웠다. 이제 우리는 단순히 위젯을 배치하는 단계를 넘어, 앱 전체의 색상과 글자, 컴포넌트 스타일을 하나의 흐름으로 관리할 수 있다.
핵심은 다음과 같다. MaterialApp의 theme, darkTheme, themeMode는 앱 전체 테마의 출발점이다. Scaffold, AppBar, NavigationBar는 화면의 기본 골격을 만들고, 이들은 현재 테마를 따라 자연스럽게 스타일링된다. Material 3에서는 ColorScheme.fromSeed로 브랜드 색상에서 조화로운 색상 역할을 만들 수 있으며, primary, surface, error, onPrimary, onSurface처럼 역할 기반으로 색을 사용해야 한다. AppBarTheme, NavigationBarThemeData, CardThemeData, FilledButtonThemeData 같은 컴포넌트 테마를 사용하면 화면마다 직접 스타일을 반복하지 않고 중앙에서 디자인 규칙을 관리할 수 있다.
앞으로 입력, 리스트, 화면 전환, 상태관리, Firebase를 배우며 앱은 점점 커질 것이다. 그때마다 테마를 잘 잡아 둔 앱은 흔들리지 않는다. 새 화면을 추가해도 같은 앱처럼 보이고, 다크 모드를 켜도 자연스럽고, 버튼 하나를 바꿔도 전체가 함께 정돈된다. 이것이 Material 3를 배우는 진짜 이유다.
다음 장에서는 사용자의 입력을 받는 방법을 본격적으로 다룬다. TextField, Form, 유효성 검사, 키보드 처리까지 배우면 앱은 이제 사용자와 대화를 시작하게 된다.
참고
1ThemeData class - material library - Dart API
4ColorScheme class - material library - Dart API
5Color - Material Design 3 - Create personal color schemes
6Introduce new ColorScheme roles for Material 3
7Use themes to share colors and font styles
8Components — Material Design 3
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
