앱 다크모드 적용기

K_Gs·2025년 5월 11일
post-thumbnail

배경

지금 제가 개발 중인 앱에서는 기본적으로 제공되는 다크모드가 있지만, 여러부분에서 고려되지 않은 부분이있어 색이 바르게 나오지 않았어요.

저는 이런게 오히려 사용성을 해친다 생각하여 다크모드를 아예 막아두었죠!

하지만 사용자 편의를 위해 빠른 시일내에 다크모드를 제공하는 것이 맞다 생각하였고, 이번에 드디어 작업하게 되었어요.

오늘은 그 과정을 이야기해보려합니다.

디자인 구조 변경

다크모드를 적용하기 이전에 먼저 앱의 전체적인 디자인적 구조을 점검하기로 하였어요.

피그마상의 디자인, 실제 앱 들을 확인하였을때 지금은 앱 내에서 색상, 텍스트 스타일등이 너무 다 다르게 적용되거나 중복되어 적용되어 있는 경우가 많았어요.

그래서 다크모드 적용의 편의성과 추후 유지보수의 편리성을 위하여 먼저 체계적인 디자인 구조를 적용하기로 하였습니다!

Material에서 약간만 벗어나기

가장 먼저 수정하기로 한 부분은 Material의 색상, 타이포를 사용하는 부분이였어요.

Material 테마는 구글에서 기본적으로 제공하는 디자인 가이드라인이고 실제로 편리하지만, 제가 생각해보았을때는 Material에서 제공하는 색상, 타이포그래피 등은 유연성이 조금 떨어진다는 생각이 들었었습니다.

컬러 스킴을 통해 다양한 상황에 따른 색상을 제공하지만, 어떤 경우에 어떤 색상이 쓰이는지, 변경시에 영향이 어떤지 알기 어려움. 이런 이유로 앱내에서도 Material에서 제공하는 색상을 그대로 쓰기보다는 Modifier.backgound() 같이 직접 지정하는 경우가 더 많음

타이포도 이와 마찬가지로 headlineSmall, bodyLarge같이 제약되어있어 오히려 애매하게 다가오곤 했어요.

그래서 저는 Material의 컴포저블들은 건들지 않고, 이런 컬러, 타이포그래피 같은 요소만 분리하여 사용하기로 결정하였어요.

타이포그래피 분리

val AppTypograpy = Typography(
    headlineMedium = TextStyle(
        fontFamily = Pretendard,
        fontWeight = FontWeight.Bold,
        fontSize = 24.sp,
        lineHeight = 32.sp,
    ),
    headlineSmall = TextStyle(
        fontFamily = Pretendard,
        fontWeight = FontWeight.Bold,
        fontSize = 22.sp,
        lineHeight = 28.sp
    ),
    titleLarge = TextStyle(
    //...

현재 앱의 타이포그래피는 위와 같이 되어있었어요. 저희가 다른 중복되는 형태의 스타일이 생겨 타이포그래피를 등록하고 싶어도 Material 에서 제공하지 않기에 추가할 수 없었죠.

그래서 이를 아예 분리하였습니다.

//AppTypograpy.kt
object AppTypograpy {
	val headlineMedium: TextStyle = TextStyle(
    	fontFamily = Pretendard,
    	fontWeight = FontWeight.Bold,
    	fontSize = 24.sp,
    	lineHeight = 32.sp,
    )
	val headlineSmall: TextStyle = TextStyle(
    	fontFamily = Pretendard,
    	fontWeight = FontWeight.Bold,
    	fontSize = 22.sp,
    	lineHeight = 28.sp
	)
	val titleLarge: TextStyle
    //...

이렇게 함으로서 스타일 추가에 좀 더 유연하게 반응할 수 있게 되었고, 디자이너가 지정한 텍스트 스타일에도 쉽게 대응할 수 있게 되었어요.

타이포그래피의 이름 자체는 Material에서 쓰이던 것과 같은데, 이를 수정하는 것은 현재 작업의 주된 부분이 아니기에 다음에 하기로 하였어요.

색상 분리

다음은 색상을 분리하기로 하였습니다.

색상의 경우 앞서 이야기 하였듯 지금도 직접 지정해주는 형식을 사용했었기 때문에 Material 테마와는 분리되어있었지만, 다크모드 지원과 편하게 관리하기 위해 별도의 파일로 분리하기로 하였어요.

지금 당장은 color.xml에 컬러를 선언해두고 이를 컴포저블 함수인 colorResource를 통해 불러오는 방식을 취하고 있었기에 아래와 같이 get()에 컴포저블 어노테이션을 적용하여 컴포저블 함수를 호출 할 수 있게 하였어요.

object AppColors {
    val background: Color
        @Composable get() = colorResource(R.color.white)
    val text: Color
        @Composable get() = colorResource(R.color.black)

이렇게 하지않으면 Context를 외부에서 주입받아 getColor를 사용해야하는데, 그것보다는 이 방식이 Compose에 더 잘 결합된다 생각했어요.

그러다 문득 한가지 생각이 들었어요.

XML vs Kotlin

이거 컬러를 그냥 참조하는거면.. xml에서 가져가는게 아니라 미리 Color를 생성해두는게 좋지않나?

사실 XML에 색상을 선언하는 것은 기존 XML 방식 뷰에서 Color 객체를 직접 참조하지 못하기에 XML로 선언하고 사용하는 것이였어요.

//colors.xml
<color name="placeholder">#FF9F9F9F</color>
->
android:background="@color/placeholder"

XML방식 뷰이거나, XML 방식 뷰와 Compose가 혼합된(마이그레이션 중)인 경우는 XML에 색을 선언하고 colorResource나 getColor로 가져오는 방식이 맞지만, Compose만 있는 상태에서는 그럴필요가 없는거죠.

저는 현재 Compose로만 구성되어있기에 XML에 컬러를 선언하는 것보다 Kotlin 코드에 컬러를 선언하는 것이 더 Compose와 결합된다 볼 수 있었습니다.

그렇기에 기존 XML에 선언된 색상을 모두 가져다 Color.kt파일을 만들어 집어 넣었어요.

//colors.xml
<color name="placeholder">#FF9F9F9F</color>
->
//Color.kt
val placeholder = Color(0xfff9f9f9)

그리고 AppColors 파일은 아래와 같이 바뀌었습니다.

val White = Color(0xFFFFFFFF)
val Black = Color(0x00000000)
//---
object AppColors {
    val background: Color
        @Composable get() = White
    val text: Color
        @Composable get() = Black

팔레트와 역할기반

이제 Material 테마에서 조금 벗어나는것은 완료되었어요.

다음으로는 여기저기 복잡하게 퍼져있는 색상들의 규칙을 정하기로 하였습니다.

현재는 색상이 어디는 조금 진한 보라, 어디는 조금더 진한 보라 이런식으로 명확하게 정해지지 않은 상태로 사용되고 있었어요.

이에 PM님의 주도하에 앱에서 사용되는 색상을 가져다 색상 팔레트를 간단하게 정리하였습니다.

하는김에 다크모드 색상도 정하였죠! 이후 이를 앱에 적용하였습니다.

저는 이때, 색상과 그 색상들이 모여있는 역할을 분리하고 싶었어요. 가령 보라와 파랑은 색상이고, 그 두개가 쓰이는 Primary라는 역할이 있는거죠.

그래서 저는 색상의 책임은 Color.kt에 두고, 이를 조합하고 앱에서 사용하는 것은 AppColors.kt에 두기로 하였어요.

//Color.kt
val Purple100 = Color(0xFFF7F2FE)
val Purple200 = Color(0xFFF4F0F9)
val Purple300 = Color(0xFFE0CCFF)

//AppColor.kt
val primaryLightBlue: Color
        @Composable get() = Blue100
val primaryLightPurple: Color
        @Composable get() = Purple100

이렇게 함으로서, 디자이너가 제공한 색상을 앱에 쉽게 추가하고 변경할 수 있게 되었고, 그 변경 및 추가에 따른 변경 범위를 쉽게 파악할 수 있게 되었어요,

이름을 보시면 Material 색상과 비슷한데, 이 또한 앞서 타이포그래피 처럼 현재는 Material에서 가져다쓰고 다음 작업에서 변경하는 것으로 하였습니다.

다크모드 적용

이렇게해서 디자인 구조의 정리가 끝났어요!

다음으로는 다크모드를 적용하였습니다.

대비?

가장 고민이였던 부분은 라이트 색상에 어떤 다크 색상을 적용하느냐였어요.

일단 라이트색상에 매칭되는 다크색상을 다 만들어두기는 하였지만, 해당 색상을 1대1로 매칭하기에는 다크모드에선 배경이 어두워져 색이 이상할 것 같았죠.

그래서 여러 사례와 인터넷을 조사해본결과, 라이트모드에서 채도가 낮을 수록 다크모드에서는 채도가 높아야한다는 것을 알게되었어요.

그래서 라이트모드의 색과 다크모드의 색을 반대편에서 매칭해주기로 하였습니다.

Blue_100 <-> Blue_Dark_600
Blue_200 <-> Blue_Dark_500

코드상 적용

코드상 적용하는 것은 간단하게 AppColors.kt에서 다크모드 여부에 따른 분기를 주었습니다.

val primaryLightBlue: Color
    @Composable get() = if(isSystemInDarkTheme()) BlueDark600 else Blue100
val primaryLightPurple: Color
    @Composable get() = if(isSystemInDarkTheme()) PurpleDark600 else Purple100

Compose에서는 isSystemInDarkTheme를 통해 시스템이 다크모드인지 쉽게 알 수 있어서 구현하기 편리하였어요.

추후 앱 내에 다크모드를 제어할 수 있는 기능을 추가하더라도 여기서 분기처리를 해주면 간단하게 구현할 수 있죠!

테마

이걸로 다크모드의 구현이 완료된줄 알았으나, 보니까 앱을 최초 실행할 때 테마로 인하여 매우 밝은 스플래시가 나오는 것을 발견하였어요.

이를 막기위해 라이트모드와 다크모드의 테마를 다르게 해주기로하였습니다.

이를 위해 values에 다크모드 조건을 단 values/night를 생성하고 themes.xml를 생성해주었어요.

그리고 내부에서 다크모드라면 DayNight모드 테마를 상속받도록 하여 스플레시가 어두워지도록 하였습니다

<!--light-->
<style name="Theme.app" parent="Theme.AppCompat.Light.NoActionBar" />
<!--dark-->
<style name="Theme.app" parent="Theme.AppCompat.DayNight.NoActionBar" />

이렇게해서 다크모드의 구현이 완료되었어요!

알게 된 점

조사하고 찾아보면서 생각보다 얻어간게 매우 많았던 작업이였어요.

  • 디자인 구조의 중요성
    • 디자인 구조를 미리 만들어두고 팀원들과 공유함으로서 일관성을 유지할 수 있고, 유지보수를 용이하게 할 수 있다는 것을 알게되었어요.
  • 다른 상용앱들의 색상, 타이포그래피 사용법
    • 디자인 시스템을 적용하기 위해 찾아보는 과정에서 다른 상용앱들은 어떤 식으로 색상, 타이포그래피를 관리하는지 조금 들여다 볼 수 있게되었어요.
    • 무엇보다 Material이 유연성이 낮다 생각하는 부분이 저만의 생각이 아니라는 것도 알게되었어요!
  • Compose에 맞는 색상 관리
    • 이전에는 XML로 색상을 관리하고 있었으니, 그냥 XML를 사용하는 느낌이였어요. 이유가 없었죠.
    • 하지만, 이번 조사를 통해 XML에 색상을 선언하는 것과, 코틀린 파일에 색상을 선언하는 것의 차이를 알고 코틀린 코드에 색상을 선언하게 되었어요.
  • 다크모드 대응 전략
    • 다크모드 대응시 라이트모드의 채도를 고려하여 다크모드 색상을 선택하는것이 바람직하다는 걸 알게되었어요.
    • 단순히 색상만 바꾸는 것이 아니라 테마나 디테일을 고려하는 것이 중요하다는 것도 알게되었지요!
profile
아직도 모르는게 많으니, 알아가고 싶은 것도 많다

0개의 댓글