Flutter 튜토리얼 22편: 테마와 다크 모드
요약
핵심 요지
- 문제 정의: 앱 전체에서 일관된 색상과 스타일을 유지하려면 각 위젯에 개별적으로 스타일을 적용하는 것은 비효율적이다.
- 핵심 주장:
ThemeData1와ColorScheme2을 사용하면 앱 전체의 시각적 일관성을 쉽게 유지하고 다크 모드도 구현할 수 있다. - 주요 근거:
MaterialApp의theme과darkTheme속성으로 라이트/다크 테마를 정의하고,themeMode3로 전환한다. - 실무 기준: Material 3가 Flutter 3.16부터 기본값이므로
ColorScheme.fromSeed()로 테마를 생성하는 것을 권장한다. - 한계: 복잡한 커스텀 디자인 시스템은 기본 테마만으로 구현하기 어려울 수 있다.
문서가 설명하는 범위
- ThemeData와 ColorScheme의 구조와 사용법
- 라이트/다크 테마 정의와 전환
- Theme.of(context)로 테마 값 접근
- 위젯별 테마 오버라이드
읽는 시간: 12분 | 난이도: 중급
참고 자료
- Use themes to share colors and font styles - Flutter 공식 테마 가이드
- ThemeData class - ThemeData API 문서
- Material Design 3 - Material 3 디자인 시스템
문제 상황
앱을 만들 때 모든 화면에서 일관된 색상, 폰트, 스타일을 유지해야 합니다. 개별 위젯마다 스타일을 지정하면 여러 문제가 발생합니다.
스타일 관리의 어려움
문제 1: 같은 색상을 여러 곳에서 하드코딩 → 변경 시 모든 곳을 수정해야 함문제 2: 다크 모드 지원 → 조건문이 곳곳에 퍼짐문제 3: 디자인 변경 → 전체 코드 수정 필요문제 4: 일관성 유지 어려움 → 실수로 다른 색상 사용해결 방법
Flutter의 테마 시스템은 앱 전체에서 공유하는 색상과 스타일을 중앙에서 관리합니다.
ThemeData로 테마를 정의하고 Theme.of(context)로 어디서든 접근할 수 있습니다.
챕터 1: ThemeData 기본 구조 이해하기
Why
NOTE테마를 사용하려면 먼저
ThemeData의 구조를 이해해야 합니다.
어떤 속성이 있고, 각 속성이 어떤 위젯에 영향을 미치는지 알아야 합니다.ThemeData → 앱 전체 스타일 정의ColorScheme → 색상 팔레트TextTheme → 텍스트 스타일
What
NOTE
ThemeData는 Flutter Material 앱의 전체적인 시각적 테마를 정의합니다.
색상, 타이포그래피, 위젯별 테마 등을 포함합니다.
How
TIP기본 ThemeData 구조
MaterialApp(theme: ThemeData(// Material 3 사용 (Flutter 3.16+ 기본값)useMaterial3: true,// 색상 팔레트colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.light,),// 텍스트 스타일textTheme: const TextTheme(displayLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),bodyMedium: TextStyle(fontSize: 14),),// 위젯별 테마appBarTheme: const AppBarTheme(centerTitle: true,elevation: 0,),cardTheme: CardTheme(elevation: 2,shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12),),),),)주요 ThemeData 속성
속성 설명 colorScheme앱의 색상 팔레트 textTheme텍스트 스타일 모음 appBarThemeAppBar 스타일 cardThemeCard 스타일 elevatedButtonThemeElevatedButton 스타일 inputDecorationThemeTextField 스타일 scaffoldBackgroundColorScaffold 배경색
Watch out
WARNINGFlutter 3.16부터 Material 3가 기본값입니다.
이전 Material 2 디자인을 사용하려면 명시적으로 설정해야 합니다.// Material 2 사용 (레거시)ThemeData(useMaterial3: false,)// Material 3 사용 (기본값, 권장)ThemeData(useMaterial3: true,)Material 2 지원은 향후 제거될 예정이므로 새 프로젝트는 Material 3를 사용하세요.
결론: ThemeData로 앱 전체의 시각적 테마를 중앙에서 정의합니다.
챕터 2: ColorScheme으로 색상 팔레트 정의
Why
NOTE앱에서 사용하는 모든 색상을 일관되게 관리해야 합니다.
ColorScheme은 Material Design의 색상 시스템을 구현하며, 접근성을 고려한 대비색도 자동으로 제공합니다.graph LR A[seedColor] --> B[ColorScheme 생성] B --> C[primary, secondary, surface 등] B --> D[onPrimary, onSecondary 등]
What
NOTE
ColorScheme은 앱의 색상 팔레트를 정의합니다.
기본 색상(primary, secondary)과 그 위에 표시되는 요소의 색상(onPrimary, onSecondary)을 포함합니다.
How
TIP시드 색상으로 ColorScheme 생성
ColorScheme.fromSeed(seedColor: Colors.deepPurple,brightness: Brightness.light,)Material 3의 색상 알고리즘이 시드 색상에서 조화로운 팔레트를 자동 생성합니다.
ColorScheme 주요 색상
// 테마 접근final colorScheme = Theme.of(context).colorScheme;// 주요 색상colorScheme.primary // 주요 브랜드 색상colorScheme.onPrimary // primary 위의 텍스트/아이콘 색상colorScheme.primaryContainer // primary의 컨테이너 색상// 보조 색상colorScheme.secondary // 보조 색상colorScheme.onSecondary // secondary 위의 색상// 표면 색상colorScheme.surface // Card, Dialog 등의 배경colorScheme.onSurface // surface 위의 텍스트// 배경색colorScheme.background // 전체 배경 (deprecated in M3)colorScheme.surfaceContainerLow // M3 배경 대체// 에러 색상colorScheme.error // 에러 표시 색상colorScheme.onError // error 위의 색상수동으로 ColorScheme 정의
ColorScheme(brightness: Brightness.light,primary: const Color(0xFF6750A4),onPrimary: Colors.white,primaryContainer: const Color(0xFFEADDFF),onPrimaryContainer: const Color(0xFF21005D),secondary: const Color(0xFF625B71),onSecondary: Colors.white,surface: Colors.white,onSurface: Colors.black,error: const Color(0xFFB3261E),onError: Colors.white,)
Watch out
WARNING
onPrimary,onSurface같은 “on” 색상을 사용해야 접근성이 보장됩니다.
대비를 직접 계산하지 마세요.// 잘못된 방법 - 대비가 보장되지 않음Container(color: Theme.of(context).colorScheme.primary,child: const Text('텍스트', style: TextStyle(color: Colors.white)),)// 올바른 방법 - 대비가 보장됨Container(color: Theme.of(context).colorScheme.primary,child: Text('텍스트',style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),),)
결론: ColorScheme.fromSeed()로 시드 색상에서 조화로운 색상 팔레트를 자동 생성합니다.
챕터 3: 다크 모드 테마 구현
Why
NOTE많은 사용자가 다크 모드를 선호합니다.
눈의 피로를 줄이고 배터리를 절약할 수 있습니다.
MaterialApp에서 라이트/다크 테마를 함께 정의할 수 있습니다.시스템 설정 → themeMode: system라이트 강제 → themeMode: light다크 강제 → themeMode: dark
What
NOTE
MaterialApp의theme속성에 라이트 테마를,darkTheme속성에 다크 테마를 정의합니다.
themeMode로 어떤 테마를 사용할지 제어합니다.
How
TIP라이트/다크 테마 정의
MaterialApp(theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.light,),useMaterial3: true,),darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.dark,),useMaterial3: true,),themeMode: ThemeMode.system, // 시스템 설정 따라감)사용자가 테마 선택
class MyApp extends StatefulWidget {const MyApp({super.key});@overrideState<MyApp> createState() => _MyAppState();}class _MyAppState extends State<MyApp> {ThemeMode _themeMode = ThemeMode.system;void setThemeMode(ThemeMode mode) {setState(() => _themeMode = mode);}@overrideWidget build(BuildContext context) {return MaterialApp(theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.light,),),darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.dark,),),themeMode: _themeMode,home: ThemeSettingsPage(currentMode: _themeMode,onThemeChanged: setThemeMode,),);}}class ThemeSettingsPage extends StatelessWidget {final ThemeMode currentMode;final ValueChanged<ThemeMode> onThemeChanged;const ThemeSettingsPage({super.key,required this.currentMode,required this.onThemeChanged,});@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('테마 설정')),body: ListView(children: [RadioListTile<ThemeMode>(title: const Text('시스템 설정'),value: ThemeMode.system,groupValue: currentMode,onChanged: (mode) => onThemeChanged(mode!),),RadioListTile<ThemeMode>(title: const Text('라이트 모드'),value: ThemeMode.light,groupValue: currentMode,onChanged: (mode) => onThemeChanged(mode!),),RadioListTile<ThemeMode>(title: const Text('다크 모드'),value: ThemeMode.dark,groupValue: currentMode,onChanged: (mode) => onThemeChanged(mode!),),],),);}}
Watch out
WARNING다크 테마에서 단순히 배경만 어둡게 하면 안 됩니다.
텍스트 대비, 그림자, 표면 색상 등을 모두 고려해야 합니다.// ColorScheme.fromSeed는 다크 모드에 최적화된 색상을 자동 생성ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.dark, // 다크 모드용 색상 생성)수동으로 ColorScheme을 정의할 때는 WCAG 대비 기준(4.5<1>1> 이상)을 확인하세요.
결론: theme과 darkTheme을 함께 정의하고 themeMode로 전환하면 다크 모드를 쉽게 구현할 수 있습니다.
챕터 4: Theme.of(context)로 테마 접근
Why
NOTE정의한 테마를 위젯에서 사용하려면
Theme.of(context)로 접근합니다.
이 방식으로 어디서든 일관된 스타일을 적용할 수 있습니다.Theme.of(context) → ThemeData.colorScheme → 색상.textTheme → 텍스트 스타일
What
NOTE
Theme.of(context)는 가장 가까운 상위 테마를 반환합니다.
MaterialApp에서 정의한 테마 또는Theme위젯으로 오버라이드된 테마일 수 있습니다.
How
TIP색상 접근
Widget build(BuildContext context) {final colorScheme = Theme.of(context).colorScheme;return Container(color: colorScheme.primaryContainer,child: Text('제목',style: TextStyle(color: colorScheme.onPrimaryContainer),),);}텍스트 스타일 접근
Widget build(BuildContext context) {final textTheme = Theme.of(context).textTheme;return Column(children: [Text('큰 제목', style: textTheme.displayLarge),Text('부제목', style: textTheme.titleMedium),Text('본문', style: textTheme.bodyMedium),],);}스타일 확장 (copyWith)
Text('커스텀 스타일',style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.red,fontWeight: FontWeight.bold,),)현재 테마 밝기 확인
Widget build(BuildContext context) {final isDarkMode = Theme.of(context).brightness == Brightness.dark;return Icon(isDarkMode ? Icons.dark_mode : Icons.light_mode,);}
Watch out
WARNING
Theme.of(context)는 BuildContext가 필요합니다.
initState()에서는 사용할 수 없습니다.// 잘못된 사용 - initState에서 context 사용 불가@overridevoid initState() {super.initState();final theme = Theme.of(context); // 에러!}// 올바른 사용 - didChangeDependencies에서 사용@overridevoid didChangeDependencies() {super.didChangeDependencies();final theme = Theme.of(context); // OK}// 또는 build에서 사용 (권장)@overrideWidget build(BuildContext context) {final theme = Theme.of(context);// ...}
결론: Theme.of(context)로 테마에 접근하고 copyWith로 필요시 스타일을 확장합니다.
챕터 5: 위젯별 테마 오버라이드
Why
NOTE특정 위젯이나 화면에서만 다른 테마를 적용해야 할 때가 있습니다.
Theme위젯으로 하위 트리의 테마를 오버라이드할 수 있습니다.상위 테마 → Theme 위젯 → 하위 위젯에 새 테마 적용
What
NOTE
Theme위젯은 하위 트리에 새로운ThemeData를 제공합니다.
copyWith()를 사용하면 기존 테마를 유지하면서 일부만 변경할 수 있습니다.
How
TIP완전히 새로운 테마 적용
Theme(data: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),),child: Builder(builder: (context) {return FloatingActionButton(onPressed: () {},child: const Icon(Icons.add),);},),)기존 테마 확장 (권장)
Theme(data: Theme.of(context).copyWith(colorScheme: Theme.of(context).colorScheme.copyWith(primary: Colors.pink,),),child: ElevatedButton(onPressed: () {},child: const Text('핑크 버튼'),),)특정 위젯 테마만 오버라이드
Theme(data: Theme.of(context).copyWith(elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(backgroundColor: Colors.green,foregroundColor: Colors.white,padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),),),),child: ElevatedButton(onPressed: () {},child: const Text('커스텀 버튼'),),)입력 필드 테마 오버라이드
Theme(data: Theme.of(context).copyWith(inputDecorationTheme: InputDecorationTheme(border: OutlineInputBorder(borderRadius: BorderRadius.circular(20),),filled: true,fillColor: Colors.grey[100],),),child: TextField(decoration: const InputDecoration(labelText: '둥근 입력 필드',),),)
Watch out
WARNING
Theme위젯 바로 아래에서Theme.of(context)를 호출하면 새 테마가 아닌 상위 테마를 참조합니다.
Builder나 별도 위젯을 사용해야 합니다.// 잘못된 방법 - 상위 테마 참조Theme(data: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink)),child: Text('텍스트',// 이 context는 Theme 위젯 위의 contextstyle: TextStyle(color: Theme.of(context).colorScheme.primary),),)// 올바른 방법 - Builder 사용Theme(data: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink)),child: Builder(builder: (context) {// 이 context는 Theme 위젯 아래의 contextreturn Text('텍스트',style: TextStyle(color: Theme.of(context).colorScheme.primary),);},),)
결론: Theme 위젯과 copyWith()로 특정 영역의 테마를 유연하게 오버라이드할 수 있습니다.
챕터 6: 테마 설정 저장하기
Why
NOTE사용자가 선택한 테마 모드를 저장해야 앱을 다시 열어도 설정이 유지됩니다.
SharedPreferences4로 간단하게 저장할 수 있습니다.사용자 선택 → SharedPreferences 저장 → 앱 재시작 → 설정 복원
What
NOTE
SharedPreferences는 키-값 형태로 간단한 데이터를 저장하는 패키지입니다.
테마 모드 같은 설정값을 저장하기에 적합합니다.
How
TIP패키지 설치
Terminal window flutter pub add shared_preferences테마 설정 저장 및 복원
import 'package:flutter/material.dart';import 'package:shared_preferences/shared_preferences.dart';class ThemeNotifier extends ChangeNotifier {static const _key = 'theme_mode';ThemeMode _themeMode = ThemeMode.system;ThemeMode get themeMode => _themeMode;ThemeNotifier() {_loadThemeMode();}Future<void> _loadThemeMode() async {final prefs = await SharedPreferences.getInstance();final value = prefs.getString(_key);_themeMode = switch (value) {'light' => ThemeMode.light,'dark' => ThemeMode.dark,_ => ThemeMode.system,};notifyListeners();}Future<void> setThemeMode(ThemeMode mode) async {_themeMode = mode;notifyListeners();final prefs = await SharedPreferences.getInstance();final value = switch (mode) {ThemeMode.light => 'light',ThemeMode.dark => 'dark',ThemeMode.system => 'system',};await prefs.setString(_key, value);}}Provider와 함께 사용
import 'package:provider/provider.dart';void main() {runApp(ChangeNotifierProvider(create: (_) => ThemeNotifier(),child: const MyApp(),),);}class MyApp extends StatelessWidget {const MyApp({super.key});@overrideWidget build(BuildContext context) {final themeNotifier = context.watch<ThemeNotifier>();return MaterialApp(theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.light,),),darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue,brightness: Brightness.dark,),),themeMode: themeNotifier.themeMode,home: const HomePage(),);}}class HomePage extends StatelessWidget {const HomePage({super.key});@overrideWidget build(BuildContext context) {final themeNotifier = context.read<ThemeNotifier>();return Scaffold(appBar: AppBar(title: const Text('테마 설정')),body: ListView(children: ThemeMode.values.map((mode) {return RadioListTile<ThemeMode>(title: Text(_themeModeLabel(mode)),value: mode,groupValue: themeNotifier.themeMode,onChanged: (value) => themeNotifier.setThemeMode(value!),);}).toList(),),);}String _themeModeLabel(ThemeMode mode) {return switch (mode) {ThemeMode.system => '시스템 설정',ThemeMode.light => '라이트 모드',ThemeMode.dark => '다크 모드',};}}
Watch out
WARNING
SharedPreferences는 앱이 완전히 종료되어도 데이터가 유지됩니다.
하지만 앱을 삭제하면 데이터도 함께 삭제됩니다.// 비동기 로딩 중 깜빡임 방지// FutureBuilder로 로딩 완료까지 스플래시 화면 표시FutureBuilder(future: themeNotifier.initialized,builder: (context, snapshot) {if (!snapshot.hasData) {return const SplashScreen();}return const MyApp();},)
결론: SharedPreferences로 테마 설정을 저장하면 앱을 다시 열어도 사용자 선택이 유지됩니다.
한계
Flutter의 테마 시스템은 강력하지만 몇 가지 제약이 있습니다.
- 복잡한 디자인 시스템: Material Design을 벗어난 복잡한 커스텀 디자인은 추가 작업이 필요합니다.
- 동적 테마: 런타임에 테마를 완전히 교체하면 전체 위젯 트리가 리빌드됩니다.
- 플랫폼 일관성: iOS의 Cupertino 스타일과 Material 스타일을 완전히 통합하기 어렵습니다.
- 테마 상속: 중첩된 Theme 위젯에서 특정 속성만 상속하기가 번거로울 수 있습니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!