앱 개발을 하다 보면 설정 페이지를 만들게 되는 시점이 옵니다. 알림, 소리, 진동 등 설정부터 개인 정보 처리 등 간단한 앱이어도 최소한의 설정 관리가 필요한 법이죠. 많은 앱들이 가지고 있는 설정이 하나 있습니다. 바로 다크 모드인데요, 앱마다 부르는 명칭은 다르지만 저는 테마 설정이라고 부릅니다. 밝은 화면을 설정하는 라이트 모드, 어두운 화면을 설정하는 다크 모드, 그리고 시스템 설정에 맞게 따라가는 시스템 설정 중에서 원하는 테마로 설정하는 페이지를 만들어 보도록 하겠습니다.
설정 데이터를 관리하는 방법은 여러 가지가 있겠지만, MVVM(Model, View, ViewModel) 구조의 프로젝트에서는 provider를 활용한 데이터 관리가 가장 좋은 것 같습니다. Provider를 사용하면 간단하게 데이터 변경 사용해서 진행하도록 하겠습니다. 우선 터미널에 다음 코드를 입력해서 프로젝트에 필요한 provider 패키지를 설치해 줍시다.
flutter pub add provider
Palette와 ColorScheme
가장 먼저 만들 것은 앱의 색상 테마입니다. primary, secondary, tertiary 등 디자인에 사용되는 명칭들을 들어보신 적이 있을 겁니다. 색과 관련된 부분은 디자이너의 영역이라 많은 개발자들은 잘 알지 못하는 영역이죠. 저 역시 모든 용어들을 다 알지는 못합니다. 하지만 primary, secondary, tertiary 까지만 알면 1인 개발을 하기엔 큰 문제가 없는 것 같습니다.
ColorScheme은 테마 색상들을 앱에 적용시켜 주는 클래스입니다. 위젯을 만들면서 바로 색을 지정하는 것이 가능하기는 하지만, 다크 모드와 라이트 모드를 전환하기 위해서는 color scheme을 통해 색을 지정하는 것이 필요합니다. 플러터에서는 material 시스템에 기반한 5개의 키 색상과 26개의 color role을 지정할 수 있습니다. 더 자세한 내용은 게시글 상단에 링크한 material.io 페이지를 읽어보시면 좋을 것 같습니다.
Primary 색은 앱의 브랜드 컬러를 나타냅니다. Appbar, 주 버튼 등 주요 컴포넌트에 많이 사용되는 색상입니다. Primary 색을 어떻게 활용하는지에 따라 앱의 분위기가 달라지기 때문에 가장 중요한 색상입니다. Primary 안에도 크게 4가지 속성으로 나눠집니다. Primary는 방금 설명한 대로 앱의 주요 컴포넌트에 사용되는 색상이고요, on primary는 primary 색으로 채워진 컴포넌트 위에 텍스트나 아이콘 등을 추가할 때 사용하는 색상입니다. Primary container는 primary 색의 주변 영역에 칠할 수 있는 색상입니다. 예를 들어 토글 버튼의 배경 색을 primary container 색으로 맞춰 주면 좋겠죠. 마지막 on primary container는 primary container 위에 사용되는 텍스트나 아이콘 등의 색으로 사용되는 색상입니다.
Secondary 색은 앱의 보조 색상을 주로 다룹니다. 덜 중요한 컴포넌트들의 색상을 주로 담당하죠. 예를 들어 선택된 리스트 아이템이 primary 색으로 칠해졌다면 나머지 아이템들은 secondary 색으로 칠함으로써 선택된 아이템을 더 강조할 수 있겠죠. Secondary 색은 primary 색과 비슷하지만 확연히 차이나는 그런 색상을 사용하면 좋습니다.
Tertiary 색은 primary와 secondary와는 대비되는 제3의 색상입니다. Primary와 secondary 사이의 밸런스를 맞춰 줌과 동시에 눈에 띄게 만들어 하이라이트를 줄 수 있는 그런 색이죠. 아래 이미지처럼 tertiary 색을 넣게 되면 사용자의 시선을 사로잡을 수 있습니다.
Palette와 ColorScheme 생성하기
이제 본격적으로 컬러 팔레트를 만들어 보겠습니다. 우선 두 개의 클래스를 생성할 건데요, LightPalette
와 DarkPalette
는 이름에서도 알 수 있듯이 라이트 모드와 다크 모드에서 사용되는 색상 데이터입니다. 저는 편의상 primary 색의 서브 컴포넌트 4가지만 지정했지만, 실제 프로젝트에서는 훨씬 더 많은 색상을 사용합니다.
class LightPalette {
static const Color primary = Color(0xffD0BCFF);
static const Color onPrimary = Color(0xff381E72);
static const Color primaryContainer = Color(0xff4F378B);
static const Color onPrimaryContainer = Color(0xffEADDFF);
}
class DarkPalette {
static const Color primary = Color(0xff6750A4);
static const Color onPrimary = Colors.white;
static const Color primaryContainer = Color(0xffEADDFF);
static const Color onPrimaryContainer = Color(0xff21005D);
}
ColorScheme
클래스를 통해서 방금 생성한 팔레트를 앱에 적용시킬 수 있습니다. Color scheme은 light와 dark 속성을 지정할 수 있는데요. 이렇게 하면 모든 속성에 대해서 색상을 지정하지 않더라도 기본 테마에 적용돼 있는 색을 자동으로 사용하게 됩니다. 필요한 색상만 적용시키려 할 때는 이렇게 간편하게 사용할 수 있습니다. 라이트 모드에 적용시킬 lightScheme
과 다크 모드에 적용시킬 darkScheme
을 만들어 보겠습니다.
class CustomColorScheme {
static const ColorScheme lightScheme = ColorScheme.light(
primary: LightPalette.primary,
onPrimary: LightPalette.onPrimary,
primaryContainer: LightPalette.primaryContainer,
onPrimaryContainer: LightPalette.onPrimaryContainer,
);
static const ColorScheme darkScheme = ColorScheme.dark(
primary: DarkPalette.primary,
onPrimary: DarkPalette.onPrimary,
primaryContainer: DarkPalette.primaryContainer,
onPrimaryContainer: DarkPalette.onPrimaryContainer,
);
}
ThemeData 생성하기
플러터에서는 ThemeData
를 통해서 많은 위젯에 테마를 바로 적용시킬 수 있습니다. 앱 전반적으로 많이 사용되는 컴포넌트라면 생성할 때마다 색상 값을 입력하기보다는 theme data를 통해 지정하는 것이 더 편리할 수 있습니다. 예를 들어 app bar의 경우 대부분의 페이지에서 동일한 배경 색과 텍스트, 아이콘 색상을 가지고 있습니다. 그럴 때 theme data에서 app bar의 색을 지정해 주면 모든 생성된 모든 app bar의 색을 고정시킬 수 있습니다. 가장 중요한 건 역시나 color scheme이겠죠. 방금 생성했던 라이트, 다크 color scheme을 지정해 주면 앱 전체에 반영이 됩니다. 라이트 모드와 다크 모드에서 사용될 두 개의 테마를 만들어 주겠습니다.
class CustomThemeData {
static ThemeData lightTheme = ThemeData(
appBarTheme: const AppBarTheme(
backgroundColor: LightPalette.primary,
titleTextStyle: TextStyle(fontSize: 18, color: LightPalette.onPrimary),
iconTheme: IconThemeData(color: LightPalette.onPrimary),
),
scaffoldBackgroundColor: LightPalette.primary,
colorScheme: CustomColorScheme.lightScheme,
);
static ThemeData darkTheme = ThemeData(
appBarTheme: const AppBarTheme(
backgroundColor: DarkPalette.primary,
titleTextStyle: TextStyle(fontSize: 18, color: DarkPalette.onPrimary),
iconTheme: IconThemeData(color: DarkPalette.onPrimary),
),
scaffoldBackgroundColor: DarkPalette.primary,
colorScheme: CustomColorScheme.darkScheme,
);
}
저는 지금까지 만든 팔레트와 테마 관련 클래스들을 palette.dart 한 파일에 저장했습니다. 모두 색과 테마에 관련된 클래스다 보니 관리와 정리가 용이하도록 한 파일로 묶었지만, 개인의 개발 스타일에 따라 여려 파일로 나눠서 저장해도 무방하다는 것을 알려드립니다.
import 'package:flutter/material.dart';
class LightPalette {
static const Color primary = Color(0xffD0BCFF);
static const Color onPrimary = Color(0xff381E72);
static const Color primaryContainer = Color(0xff4F378B);
static const Color onPrimaryContainer = Color(0xffEADDFF);
}
class DarkPalette {
static const Color primary = Color(0xff6750A4);
static const Color onPrimary = Colors.white;
static const Color primaryContainer = Color(0xffEADDFF);
static const Color onPrimaryContainer = Color(0xff21005D);
}
class CustomColorScheme {
static const ColorScheme lightScheme = ColorScheme.light(
primary: LightPalette.primary,
onPrimary: LightPalette.onPrimary,
primaryContainer: LightPalette.primaryContainer,
onPrimaryContainer: LightPalette.onPrimaryContainer,
);
static const ColorScheme darkScheme = ColorScheme.dark(
primary: DarkPalette.primary,
onPrimary: DarkPalette.onPrimary,
primaryContainer: DarkPalette.primaryContainer,
onPrimaryContainer: DarkPalette.onPrimaryContainer,
);
}
class CustomThemeData {
static ThemeData lightTheme = ThemeData(
appBarTheme: const AppBarTheme(
backgroundColor: LightPalette.primary,
titleTextStyle: TextStyle(fontSize: 18, color: LightPalette.onPrimary),
iconTheme: IconThemeData(color: LightPalette.onPrimary),
),
scaffoldBackgroundColor: LightPalette.primary,
colorScheme: CustomColorScheme.lightScheme,
);
static ThemeData darkTheme = ThemeData(
appBarTheme: const AppBarTheme(
backgroundColor: DarkPalette.primary,
titleTextStyle: TextStyle(fontSize: 18, color: DarkPalette.onPrimary),
iconTheme: IconThemeData(color: DarkPalette.onPrimary),
),
scaffoldBackgroundColor: DarkPalette.primary,
colorScheme: CustomColorScheme.darkScheme,
);
}
Setting provider 생성하기
이제 본격적으로 앱 구현에 들어가 보도록 하겠습니다. 우선은 설정 데이터, 특히 테마에 대한 데이터를 담고 있을 provider를 생성할 건데요, ChangeNotifier
클래스를 통해서 위젯이 데이터의 변화를 손쉽게 감지할 수 있도록 해주겠습니다. SettingViewModel
을 생성하고 필요한 내용들을 추가해 보도록 하겠습니다. themeMode
변수는 앱의 테마 정 값을 가지고 있는 변수입니다. ThemeMode.light
, ThemeMode.dark
, ThemeMode.system
을 통해서 세 가지 테마를 지정할 수 있습니다. 저는 기본 값으로 시스템 설정을 지정했지만 원하시는 스타일에 맞게 지정하는 것도 가능합니다.
테마를 변경하는 changeTheme()
함수는 입력받은 테마로 themeMode
변수를 변경하는 함수입니다. 데이터를 변경한 후에는 notifyListeners()
함수를 호출해서 데이터에 변화가 있다고 알리게 됩니다. 그러면 위젯이 새로 그려지면서 색도 함께 변하는 원리입니다.
마지막으로 getThemeName()
함수는 지금 적용되어 있는 테마를 한글로 반환해 주는 함수입니다. 위젯을 그릴 때 사용하기 위한 도우미 함수로 보면 되겠습니다.
import 'package:flutter/material.dart';
class SettingViewModel with ChangeNotifier {
ThemeMode themeMode = ThemeMode.system;
/// 테마 변경
void changeTheme(ThemeMode mode) {
themeMode = mode;
notifyListeners();
}
/// 설정 화면에 표시할 테마 이름 값 return
String getThemeName() {
switch (themeMode) {
case ThemeMode.light:
return '라이트 모드';
case ThemeMode.dark:
return '다크 모드';
case ThemeMode.system:
default:
return '시스템 설정';
}
}
}
Main.dart 구성하기
설정 화면을 구성하기 전에 main 함수를 먼저 수정해 보겠습니다. MainApp
을 실행할 때 ChangeNotifierProvider
를 적용시켜서 앱 전체에서 방금 생성한 뷰모델을 감지할 수 있도록 해주겠습니다.
Material app에는 아까 생성했던 ThemeData
를 지정해서 우리가 원하는 테마를 적용시킬 수 있도록 해주겠습니다. theme
속성에는 라이트 모드 테마를, darkTheme
속성에는 다크 모드 테마를 적용해 주면 됩니다. themeMode
속성에는 뷰모델에 저장된 themeMode
변수를 지정하면 됩니다. 이제 테마가 변경되면 자동으로 color scheme에 맞춰 색이 변하게 될 것입니다.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:settings_dark_mode/constants/palette.dart';
import 'package:settings_dark_mode/view_models/setting_view_model.dart';
import 'package:settings_dark_mode/views/home_view.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => SettingViewModel(),
child: const MainApp(),
),
);
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: CustomThemeData.lightTheme,
darkTheme: CustomThemeData.darkTheme,
themeMode: Provider.of<SettingViewModel>(context).themeMode,
home: const Scaffold(
body: Center(
child: HomeView(),
),
),
);
}
}
Setting View 구성
대망의 설정 페이지를 만들어 보겠습니다. 화면 구성은 간단합니다. InkWell
위젯을 사용해서 테마 설정 버튼을 하나 만들어주고, 버튼을 탭 했을 때 showModalBottomSheet
를 통해 테마를 선택할 수 있는 하단 모달이 뜨도록 구현했습니다.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:settings_dark_mode/view_models/setting_view_model.dart';
class SettingView extends StatefulWidget {
const SettingView({super.key});
@override
State<SettingView> createState() => _SettingViewState();
}
class _SettingViewState extends State<SettingView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('설정'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: () {
Navigator.pop(context);
},
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
InkWell(
onTap: () {
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: (context),
builder: (BuildContext context) {
return themeModal();
},
);
},
child: Container(
margin: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'테마',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary),
),
Text(
Provider.of<SettingViewModel>(context).getThemeName(),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary),
),
],
),
),
)
],
),
);
}
}
모달은 iOS의 디자인을 참고해서 제일 아래에 취소 버튼이 있고, 그 위에 세 개의 테마를 선택할 수 있도록 만들어 보았습니다. 테마 버튼 사이에는 divider
위젯을 추가해서 구분이 가능하도록 해 주었습니다. 각 테마를 선택했을 때는 뷰모델에 있는 changeTheme
함수를 호출해서 선택한 테마로 변경을 해주도록 하겠습니다.
// 테마 선택 모달
Widget themeModal() {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Ink(
height: 168,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: Column(children: [
// 라이트 모드 버튼
InkWell(
onTap: () {
Provider.of<SettingViewModel>(context, listen: false)
.changeTheme(ThemeMode.light);
Navigator.pop(context);
},
borderRadius:
const BorderRadius.vertical(top: Radius.circular(10)),
child: Container(
height: 56,
alignment: Alignment.center,
child: Text(
'라이트 모드',
style: TextStyle(
color:
Theme.of(context).colorScheme.onPrimaryContainer),
),
),
),
Divider(
height: 0,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
// 다크 모드 버튼
InkWell(
onTap: () {
Provider.of<SettingViewModel>(context, listen: false)
.changeTheme(ThemeMode.dark);
Navigator.pop(context);
},
child: Container(
height: 56,
alignment: Alignment.center,
child: Text(
'다크 모드',
style: TextStyle(
color:
Theme.of(context).colorScheme.onPrimaryContainer),
),
),
),
Divider(
height: 0,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
// 시스템 설정 버튼
InkWell(
onTap: () {
Provider.of<SettingViewModel>(context, listen: false)
.changeTheme(ThemeMode.system);
Navigator.pop(context);
},
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(10)),
child: Container(
height: 56,
alignment: Alignment.center,
child: Text(
'시스템 설정',
style: TextStyle(
color:
Theme.of(context).colorScheme.onPrimaryContainer),
),
),
),
]),
),
),
const SizedBox(height: 8),
// 취소 버튼
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Ink(
height: 56,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: InkWell(
onTap: () {
Navigator.pop(context);
},
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Container(
height: 56,
alignment: Alignment.center,
child: Text(
'취소',
style: TextStyle(
color:
Theme.of(context).colorScheme.onPrimaryContainer),
),
),
),
),
),
],
),
);
}
Home View 구성
이제 마지막으로 변화를 확인할 홈 화면을 만들어 보도록 하겠습니다. 간단하게 중앙에 컨테이너 하나와 텍스트를 그려주었습니다. Theme.of(context).colorScheme
을 사용해서 색을 지정해 주면 테마가 변경될 때 컨테이너와 텍스트 색상도 함께 변경되는 것을 확인할 수 있습니다. App bar의 우측에는 설정 페이지로 이동할 수 있는 버튼을 하나 달아주도록 하겠습니다.
import 'package:flutter/material.dart';
import 'package:settings_dark_mode/views/setting_view.dart';
class HomeView extends StatefulWidget {
const HomeView({super.key});
@override
State<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends State<HomeView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('홈'),
actions: [
IconButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const SettingView()));
},
icon: const Icon(Icons.settings),
),
],
),
body: Center(
child: Container(
width: 200,
height: 200,
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Text(
'텍스트입니다.',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
),
),
);
}
}
이제 앱을 실행한 후 설정에서 테마를 변경해 보도록 하겠습니다. 설정한 테마에 맞춰서 배경, 컨테이너, 텍스트, 버튼 등 컴포넌트들이 바뀌는 것을 확인할 수 있을 겁니다.
팔레트와 color scheme을 한 번 설정해 두면 색을 변경할 때도 모든 컴포넌트들에 대해서 일일이 수정할 필요 없이 팔레트만 수정하면 된다는 장점이 있습니다. 프로젝트 초기에는 조금 귀찮은 작업일 수 있지만 앱의 유지 보수 등 여러 측면들을 고려해 볼 때 꼭 필요한 작업이라 생각됩니다.
'프로그래밍 > 플러터' 카테고리의 다른 글
Homebrew를 사용한 맥북 플러터 설치하기 (0) | 2024.08.09 |
---|---|
플러터 커스텀 카메라 만들기 (2) | 2024.07.19 |
플러터 시작하기 05. 버튼 종류를 알아보자! (0) | 2024.05.16 |
플러터 시작하기 02. Appbar 꾸미기 (0) | 2024.05.14 |
플러터 시작하기 04. FloatingActionButton 꾸미기 (0) | 2024.05.14 |