소프트웨어 마에스트로에서 프로젝트를 하면서 다크 모드를 적용해야 하는 일이 있었다.
우선 지금 진행 중인 프로젝트는 이른바 "태블릿 문제 풀이 플랫폼"인데, 자세한 설명은 추후 게시하려고 한다.
아무튼, 이 글을 쓰게 된 이유는 프로젝트의 특수성 때문에 다크 모드 구현에 신경 쓸 것들이 많았기 때문이다.
일반적인 다크모드
사실 Flutter의 MaterialApp
은 darkTheme과 themeMode를 지정할 수 있기 때문에, 다크 모드를 쉽게 구현할 수 있다.
MaterialApp(
...
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.dark,
);
이렇게 하면 앱의 theme은 자동으로 기본 다크모드 테마로 변경된다.
themeMode를 ThemeMode.system
으로 지정하면 시스템의 다크모드 설정을 따라 자동으로 theme이 결정된다.
그렇다면 뭐가 문제일까?
프로젝트에서 적용하고자 했던 다크 모드는 약간 독특한 점이 있는데,
- 특정 페이지에서만 다크 모드가 적용되어야 하고
- 유저가 앱 내에서 다크 모드를 실시간으로 끄고 켤 수 있어야 하며
- 하얀 배경과 검정 글자의 이미지는 검정 배경과 하얀 글자로 변경되어야 하며
- 유저의 검은색 필기도 하얀색으로 변경되어야 한다.
뿐만 아니라 기본 다크 모드 테마가 아닌 새로운 테마를 적용하고 싶었기 때문에 추가적인 작업이 더 필요했다.
이렇게 나열하고 보니 엄청 많은 일을 해야 할 것만 같은데, 생각보다 쉽게 쉽게(?) 해결했다.
특정 페이지에서만 다크 모드 적용하기
위 사진처럼 원래 화면은 라이트 모드이고, 문제 풀이 화면만 다크 모드로 만드는 것이 목표였다.
앞에서 설명한 방식은 앱 전체에 적용되는 방식이기 때문에, 화면마다 다른 모드를 적용하기에는 부적절했다.
따라서 특정 부분에만 테마를 적용하는 방식을 적용해야 했는데, Theme
으로 해당 페이지를 감싸는 방식으로 해결하였다.
Theme(
data: /* ThemeData */,
child: /* 화면에 들어갈 위젯 */,
);
data에는 ThemeData를 입력하면 되는데, 기본 다크 모드 테마를 사용하려면 ThemeData.dark()
를 사용하면 된다.
위 프로젝트에서는 추가적인 색상이 필요하기 때문에 직접 ThemeData를 만들어 사용하였다.
ThemeData를 만드는 방법은 공식 문서에서 확인할 수 있다.
다크모드 켜고 끄기
이 부분은 이미 앱에서 Provider 패키지를 이용한 MVVM 패턴을 이용하고 있어 이를 활용하였다.
(물론 StatefulWidget
과 setState()
로도 구현 가능하다.)ChangerNotifier
를 상속하는 클래스를 만들어 context.watch<T>()
로 view에서 데이터를 불러왔다.
context.watch<T>()는 데이터가 변경될 때마다 위젯을 재빌드하기 때문에, 테마의 변경을 즉각 반영할 수 있다.
간단한 예시를 보자.
/// theme_view_model
class ThemeViewModel extends ChangeNotifier {
bool _isDark = false;
bool get isDark => _isDark;
void changeThemeMode() {
_isDark = !_isDark;
notifyListeners();
}
}
/// main
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => ThemeViewModel(),
child: const MaterialApp(
home: MyPage(),
),
),
);
}
class MyPage extends StatelessWidget {
const MyPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final themeViewModel = context.watch<ThemeViewModel>();
return Theme(
data: themeViewModel.isDark ? ThemeData.dark() : ThemeData.light(),
child: Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () {
themeViewModel.changeThemeMode();
},
child: Text(themeViewModel.isDark ? "Dark" : "Light"),
),
),
),
);
}
}
view에서는 viewModel로부터 불러온 값을 이용하여 테마와 버튼 텍스트를 결정하며,
버튼이 눌리면 viewModel은 notifyChangers()
를 통해 값이 변경되었음을 알린다.
만약 테마가 번경되는 부분이 너무 뚝뚝 끊기는 느낌이 든다면, Theme 대신에 AnimatedTheme
을 사용하면 된다.
참고로 실제 프로젝트에서는 앱을 종료하더라도 다크 모드의 활성화 여부를 기억해야 하기 때문에, ShardPreferences 패키지를 이용하여 저장하였다.
안드로이드 개발을 하는 분들이라면 아시겠지만, 데이터를 key-value의 형태로 쉽게 저장하고 불러올 수 있도록 도와주는 친구이다.
이미지 색상 반전하기
솔직히 가장 어려울 것이라고 예상했던 부분이 이미지였다.
하지만, ColorFilter
를 이용하여 쉽게 해결할 수 있었다.
다만 색상을 반전하는 필터는 색상 blend와 다르게 직접 필터를 만들어야 했는데, ColorFilter.matrix()
함수를 이용하였다.
Colorfilter.matrix()의 인자에는 double로 이루어진 1차원 List가 들어가는데, 5x5 행렬 중 일부를 직렬화한 값이다.
위 사진에서 가운데 5x5 행렬의 원소들 중 상수를 제외한 부분을 [a00, a01, ... , a33, a44]의 형태로 넣어주면 된다.
이렇게 되면 원래의 색상 (R, G, B, A)가 위의 식을 통해 새로운 색상값 (R', G', B', A')값이 도출되게 된다.
따라서 우리가 원하는, 색상을 반전키는 ColorFilter는 이렇게 만들 수 있겠다.
const ColorFilter reverseFilter = ColorFilter.matrix(<double>[
-1, 0, 0, 0, 255,
0, -1, 0, 0, 255,
0, 0, -1, 0, 255,
0, 0, 0, 1, 0, // 투명도는 반전 X
]);
최종적으로 이미지 색상을 반전하는 코드는 아래와 같이 작성할 수 있다.
child에 이미지가 아닌 위젯을 넣어도 색상이 모두 반전되니 주의하자.
ColorFiltered(
colorFilter: reverseFilter,
child: CachedNetworkImage(
...
),
),
필기 색상 반전하기
처음 이 기능을 어떻게 구현할지 2가지의 방법을 생각해내었다.
첫 번째는 이미지와 함께 필기의 색상도 반전해버리는 방법이었다.
필기 영역까지 ColorFiltered로 감싸면 되기 때문에 구현하기도 가장 쉬운 방법이었다.
하지만 이 방법은 금세 기각되었는데, 그 이유는 말 그대로 "색상이 반전되기 때문"이다.
노란색 형광펜이 파란색이 되어 사용자에게 주는 이질감과 눈뽕(?)은 너무나도 강렬했다...
두 번째는 다크 모드에서 검은색 펜을 하얀색 펜으로 변경하는 방법이다.
다른 색상은 그대로 유지되고, 검은색만 하얀색으로 바뀌기에 사용자가 느끼는 이질감도 적다.
하지만 하나의 문제가 있었으니... 바로 사용자가 다크 모드를 켜고 끌 수 있다는 점이다.
다크 모드에서 하얀색 펜으로 필기한 후 라이트 모드로 바꾸면, 하얀 바탕에 하얀 필기가 남게 되는 셈이다.
따라서 최종적으로 결정한 방법은 필기 내부에서 검정이라는 색상을 테마에 맞게 처리하는 방법이다.
즉 검정 필기는 내부적으로는 그대로 검정으로 저장하되, 테마가 다크 테마면 하얀색으로 보여주기만 하는 방법이다.
투명도가 있는 검은색도 같은 투명도의 하얀색으로 보여주기 위해 몫, 나머지 연산을 활용하였다.
Color getDisplayColor(int value, bool isDarkMode) {
return Color(
isDarkMode && value % 0x1000000 == 0 // 다크모드이고 투명도 무관 검정이면
? (value ~/ 0x1000000) * 0x1000000 + 0xFFFFFF // 투명도 유지하고 하양으로 변경
: value, // 해당 없으면 색상 그대로
);
}
한계점
사실 한계점이라고 말하긴 그렇기만, 문제 풀이를 다크 모드에서 한다고 해서 눈이 덜 피로한 지는 잘... 모르겠긴 하다...이와 별개로 다크 모드를 켜고 끌 때 Color에만 애니메이션이 적용되고, 이미지에는 아직 적용되지 않아 약간 어색한 감이 있다.
(2022.10.4 업데이트) TweenAnimationBuilder
를 사용하여 이미지에도 테마 변경 애니메이션을 적용하였다.
TweenAnimationBuilder와 관련된 내용은 공식 문서 참고.