[Android][Kotlin] 테마 적용

D.O·2023년 10월 29일
0
post-thumbnail

들어가며..

사람들은 먹는 음식, 입는 옷, 감상하는 미술에 개인의 취향을 반영합니다. 이처럼 디지털 환경에서도 개인의 취향과 스타일을 반영하는 것이 중요합니다. 그렇기 때문에 많은 앱 및 웹사이트에서 테마를 제공하고 있습니다.

나는 내가 작업 중인 Mineme 프로젝트에 대해서 Theme에 대응하는 부분을 구현하려고 했다. 초기에는 DarkTheme만 작업하려고 했으나 작업 중에 Dynamic Theme에 대해서도 알게되어서 이 부분도 같이 구현하였다.

이번 포스트에서는 Jetpack Compose를 사용하여 앱에 테마를 적용하고, 사용자가 동적으로 테마를 전환할 수 있도록 하는 방법을 알아보겠습니다.

Theme란?

테마는 앱의 전반적인 디자인과 스타일을 결정합니다.

이는 색상 뿐만 아니라 타이포그래피, 공간 등을 포함합니다. Jetpack Compose에서는 MaterialTheme 통해 이를 쉽게 적용할 수 있습니다.

DarkTheme

Dark Theme은 사용자 인터페이스의 밝은 색상 요소들을 어두운 색상으로 전환하는 시각적 모드입니다. 특징으로는 배경색이 어둡고, 텍스트 및 아이콘은 밝아지게 하는게 일반적이라고 합니다.

Material Android 라이브러리의 1.1.0 버전 이상을 사용하면 Android Q 이상 부터 다크 테마 기능을 지원할 수 있다고 합니다. MaterialTheme는 기본 DarkTheme에 대한 팔레트가 제공된다. Material DarkTheme는 Material Color System을 활용하여 팔레트 색상에 대한 기본 다크 테마 값을 제공한다.

동적 테마

동적 테마는 사용자의 선택, 앱의 상태, 또는 특정 이벤트에 의해 테마를 동적으로 전환할 수 있는 기능이다.

Material 3는 최신 Android 시각 스타일 및 시스템 UI와 정렬되도록 설계되었으며, 동적 색상과 같은 독점 기능을 제공하는데 동적 색상은 Android 12 (API 레벨 31) 이상에서 사용할 수 있다.

Material3를 사용하면서 Android 12 이상일 때 지원가능하다는 의미이다.

Material3

간단히 Material3에 대해 알아보겠다.

Material Design 3 (Material 3 or M3)는 Material Design의 다음 단계로, 업데이트 된 테마, 컴포넌트, 그리고 Material You 개인화 기능들을 포함하고 있다. 이는 동적 색상 같은 특징을 포함한다.

Material 3는 Jetpack Compose를 사용하여 Material Design 3로 UI를 구축할 수 있게 해주는 라이브러리를 제공한다.

즉 이게 의미하는게 무엇이냐?

Material 3는 이전 버전인 Material Design 2 (Material 2 또는 M2)와 다르게, 색상 체계, 타이포그래피 및 모양과 같은 서브시스템을 포함하는 테마를 제공하며, 이러한 값을 사용자 정의하면 이러한 변경 사항이 자동으로 Material 3 컴포넌트에 반영한다는 것
즉 Material 3 기반의 Compose를 사용하면 간단히 디자인 개인화를 구현할 수 있다는 것이다.

공식문서에서는 Material 2에서 Material 3로의 마이그레이션은 단계적으로 수행하기를 권장한다.

하지만 나의 Mineme_new 프로젝트는 애초에 Material3기반의 compose를 사용했기에 이러한 작업은 필요없다. (이전에 했던 XML 기반 Mineme 프로젝트는 Material2를 사용하였다.)

다시 동적 테마로 돌아가서 동적 테마가 생소한 사람이 있을 것이다.(그 이유는 내가 생소했기 때문…)

동적 테마는 앱 설정에서 변경 가능하다.

기본적으로 설치되어있는 계산기나 전화 등의 앱에는 이미 이러한 동적 테마에 대한 대응이 되어있는 상태이다.

적용 및 원리

이제 Compose 기반 앱 전체에 이 테마를 적용하는 방법을 알아보겠다.

나의 Mineme 프로젝트에서 테마 적용은 setupContent 함수 내부의 setContent 블록에서 이루어진다.

DoTheme 컴포넌트는 테마를 적용하며, darkThemedisableDynamicTheming 매개변수를 통해 테마의 설정을 제어하는데 여기서 사용된 두 shouldDisableDynamicTheming , shouldUseDarkTheme 함수는 아래와 같다.

두 함수는 MainActivityUiState에 따라 테마 옵션을 결정한다

두 함수는 MainActivityUiState에 따라 테마 옵션을 결정한다

  1. shouldDisableDynamicTheming 함수:
    • 로딩 상태에서는 동적 테마를 비활성화합니다(false 반환).
    • 성공 상태에서는 사용자 데이터의 useDynamicColor 값이 false일 경우에 동적 테마를 비활성화합니다.
  2. shouldUseDarkTheme 함수:
    • 로딩 상태에서는 시스템이 다크 테마를 사용하고 있는지 확인한다. (isSystemInDarkTheme() 호출).
    • 성공 상태에서는 사용자 데이터의 darkThemeConfig 값에 따라 다크 테마 사용 여부를 결정합니다:
      • 시스템 설정을 따르면 시스템이 다크 테마를 사용하고 있는지 확인
      • 라이트 테마 설정이면 다크 테마를 사용하지 않습니다(false 반환).
      • 다크 테마 설정이면 다크 테마를 사용합니다(true 반환).

즉 정리해서 말하자면 DoTheme 컴포넌트는 테마를 적용하며, darkTheme 및 disableDynamicTheming 매개변수를 통해 테마 설정을 제어한다. 이 값들은 로컬에 저장되어 있으며, 앱의 생명주기에 따라 uiState의 최신 값을 유지 및 관찰하여 이 값에 따라 테마를 실시간으로 적용한다.

이게 가능한 이유는 MaterialTheme의 특성때문이다.

MaterialTheme는 상위에서 설정된 테마 값을 Composable 트리의 하위 요소들에게 전달할 수 있게해준다.
이를 통해 테마 값이 변경되면, 이 값에 의존하는 Composable 요소들이 자동으로 업데이트되고 재구성된다.

좀 더 자세히 알아보기 위해 MaterialTheme에 대해 자세히 알아보자.

일단 내가 구현한 DoTheme는 아래와 같다
DoTheme는 설정 값을 통해서 MaterialTheme의 colorSchme, typography, content 등을 적용할 수 잇는 부분이다.

동적테마를 지원하고 사용을 활성화한다면 darktheme 여부에 따른 ColorScheme를 적용한다..

또한 BackgroundTheme 객체를 생성하여 배경 테마를 설정한다.

그 다음으로 이 부분이 하위 앱에 전체적으로 영향을 주는 결정적인 부분인데

CompositionLocalProvider 를 통해 주어진 LocalBackgroundTheme을 제공하여 하위 Composable 요소들에게 접근 가능하게 한다. 이렇게 설정하면, MaterialTheme와 그 내부의 content 블록을 포함한 모든 하위 Composable 요소들은 LocalBackgroundTheme을 사용하여 배경 테마에 접근할 수 있게 된다.

또한 MaterialTheme은 Composable의 상위 요소로 설정되며, 여기서 정의된 테마 설정은 이 Composable의 하위 트리에 적용된다. 따라서 MaterialTheme 내에서 정의된 colorScheme, typography 등의 값은 하위 Composable 요소들에게 전달되며, 이러한 값들은 앱 전체의 디자인 및 스타일에 영향을 주는 것이다. 이렇게 함으로써, MaterialTheme는 앱 전체에 일관된 테마와 스타일을 제공할 수 있는 것이다.

먼저 여기서 내가 CompositionLocalProvider로 제공한 LocalBackgroundTheme가 어떻게 사용되는지 설명을 하자면

나의 앱은 추가적으로 DoBackground라는 Composable로 감싸지는데 이는 DoTheme 하위에 있는 것이고 따라서 LocalBackgroundTheme를 이용할 수 있다.

따라서 LocalBackgroundTheme.current를 이용해서 상위 Composable인 DoTheme에서 설정한 값들을 가져와서 Surface 컴포저블에 넘긴다.

이는 surface 도입은 공식 문서를 참조하여 만든 것인데

공식문서에서는 요소의 Background Color 설정 시 Surface를 사용하기를 권장한다.

그 이유는 contentColorFor() 메서드는 테마 색상에 적절한 '설정' 색상을 검색한다.
예를 들어 primary 배경을 설정하면 onPrimary가 콘텐츠 색상으로 설정된다. 테마가 아닌 배경 색상을 설정하는 경우 적절한 콘텐츠 색상도 지정해야한다.

LocalContentColor를 사용하여 현재 배경과 대조되는 현재 콘텐츠 색상을 검색한다.

LocalContentColor는 현재 Composable의 범위에서 사용할 수 있는 기본 콘텐츠 색상을 제공하는 CompositionLocal이다.

기본적으로 제공되는 Composable을 살펴보면 이 LocalContentColor를 내부적으로 이용하게 되어 있다.

이 때문에 Theme에 따른 LocalContentColor를 지정해주면 하위에 내부 Composable이 전부 변경되는 구조인 것이다.

아무튼 Surface를 사용하기 전에는

@Composable
fun CustomBackgroundContentExample() {
    // 임의의 배경 색상
    val customBackgroundColor = Color(0xFFE57373)  // 예: 어떤 빨간색

    // contentColorFor()를 사용하여 적절한 콘텐츠 색상 검색
    val contentColor = contentColorFor(customBackgroundColor)

    // Box 안에서의 콘텐츠는 적절한 색상을 사용해야 합니다.
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(customBackgroundColor),
        contentAlignment = Alignment.Center
    ) {
        // LocalContentColor를 사용하여 적절한 콘텐츠 색상을 제공
        CompositionLocalProvider(LocalContentColor provides contentColor) {
            Text("This text is legible on the custom background")
        }
    }
}

내부적으로 이런식으로 contentColorFor함수를 사용하여 LocalContentColor를 구현해주어야하는데

공식문서에 따르면 Surface는 적절한 콘텐츠 색상을 설정하기 때문에 간단히 Surface를 사용하기를 권장하는 것이다.

Surface 내부적으로는 거의 유사하게 구현되어있다

이전에 우리가 제공해주었던 Background 색상에 대비되는 contentColor를 가지게 구현되어있다

함수가 궁금해서 좀 더 분석해보았다.

이 함수는 backgroundColor가 현재 ColorScheme의 어떤 배경 색상과 일치하는지 확인하고, 일치하는 색상이 있으면 해당하는 내용 색상을 반환합니다. 예를 들어, backgroundColorColorScheme.primary인 경우 ColorScheme.onPrimary를 반환합니다. 만약 backgroundColor가 테마의 배경 색상과 일치하지 않는다면, LocalContentColor의 현재 값을 반환한다.

이제 어느정도 MaterialTheme가 왜 일관된 스타일 유지에 유용한지 원리가 이해가 되었을 것입니다.

정리해서 말하자면 MaterialTheme는 Android Jetpack Compose의 핵심 구성 요소 중 하나로, 앱의 주요 디자인 특성을 일관되게 정의하고 적용하는 데 중요한 역할을 한다. 이를 통해 사용자는 일관된 디자인 및 사용자 경험을 누릴 수 있으며, 개발자는 중복 코드 없이 디자인을 효과적으로 적용할 수 있습니다.

이는 내부적으로 Material Compose 및 MaterialTheme가 CompositionLocalProvider를 활용하기 때문 CompositionLocalProvider는 Compose에서 상위 Composable로부터 파생된 데이터를 자식 Composable에게 전달하는 매커니즘입니다. 따라서, MaterialTheme를 사용할 때 앱의 모든 UI 구성 요소가 자동으로 주어진 테마 설정에 따라 업데이트됩니다.

물론, 일관된 테마를 유지하면서도 특정 부분을 MaterialTheme를 통해 기본적으로 적용되는 방식과 다르게 디자인하고 싶을 때가 있습니다. 이러한 부분은 개별적으로 커스터마이징하여 적용할 수 있으며, 이 부분에 대해 알아보겠습니다.

처음으로 특정 부분에 다른 색상을 주고 싶다면

MaterialTheme의 colorScheme을 통해 앱 내에서 사용되는 주요 색상을 정의할 수 있습니다.

기본으로 제공되는 colorScheme에 정의된 색상을 변경하려면, 단순히 원하는 색상으로 값을 재정의하면 됩니다. 이를 통해 이전에 커스텀 테마를 정의할 때 지정했던 색상 스키마를 원하는대로 변경하여 적용할 수 있습니다.

그렇지만 기본적으로 제공되는 colorScheme 외에 앱의 특정 요구사항에 맞게 완전히 커스텀한 색상 스키마를 정의하고 싶을 수 있습니다. 이 경우, ColorScheme 객체를 직접 생성하여 원하는대로 커스터마이즈하면 됩니다.

VectorDrawable Theme 적용 문제

이제 내 Mineme 앱에서 이러한 Theming에 대응하기 위해 기존에 색상 값이 직접 Color()로 하드코딩되어 있던 모든 부분을 MaterialTheme를 사용하여 래핑하였습니다

이렇게 하여 이제 앱을 실행시켜 보았습니다.

음 특정 부분에서 완전히 Color가 적용이 안된 부분이 있습니다

특히 왼쪽 상단은 이미지를 BackGround를 포함해서 한번에 넣어서 그렇다고 하더라도 바텀 Icon에 대한 색상값은 하나로 완전히 뒤덮힌것을 볼 수 있었습니다.

이를 해결하기 위해서 Icon 부분을 살펴보았는데 색상이 LocalContentColor에 의해서 적용됩니다.

저 같은 경우는 BottomIcon을 디자이너 친구가 Figma에 정의해둔 Vector Drawble를 가져와서 그대로 사용하였습니다.

Theme를 적용하기전에는 그냥 이 색상이 정의된 Vector을 그대로 사용하였는데 이 부분에 Theme가 적용이 되면서 색상 변화에 대응하지 못하고 있는 것 같았다.

따라서 각각의 다른 색상 적용이 필요해보였다.

먼저 Drawble를 분리하였다 path를 단위로 각각을 분리하였다.

이 벡터 그래픽은 3개의 주요 경로로 구성되어 있었다.
첫 번째 path는 파란색 사각형,
두 번째 path는 연한 갈색의 반원 형태
마지막 path는 검은색의 복잡한 형태의 테두리

결과적으로 3부분으로 분리되었다

이것을 아래 처럼 하나의 Icon 처럼 보이게 만들기 위해 Box를 이용해서 내부적으로 각각의 Drawble를 모두 포함시키고 각각의 tint를 설정하였다.

색상들은 Dark 부분에 대한 색상은 내가 임의로 정하였다.

이렇게 정한 부분을 Custom ColorScheme에 적용하였다

다른 부분의 BottomIcon도 동일하게 구현하였다.

기존에 DrawbleId 만 넘겨주던 로직에서 Composable을 넘기도록 변경 한 후 앱을 다시 실행 해보았다.

Light Mode

Dark Mode

마지막으로 Setting부분에서 앱 내부에서 이러한 Theme 설정을 동적으로 할 수 있고 그 설정값이 저장되게 하였다.

전체 코드는 내 Mineme 프로젝트의 Settings 모듈에 가면 볼 수 있다

여기서 중요한 점은 동적테마는 안드로이드 12 이상부터 지원하기 때문에 visible을 이에 따라 조정하는 것이 중요하다.

결과

앱의 설정 부분에서 앱의 테마를 지정하고 실시간으로 적용되는 것을 확인

테스트 코드 작성

마지막으로 아래와 같이 stateIsSuccessAfterUserDataLoaded 테스트코드를 작성하였다.

stateIsSuccessAfterUserDataLoaded는 userDataRepository에서 특정 설정을 변경한 후 SettingsViewModel의 settingsUiState가 예상대로 변경되는지 검증하는 테스트 코드이다.

통과되는것을 확인하고 Develop Branch에 병합을 진행하였다.

마무리

Android Jetpack Compose를 활용해 테마를 적용하니 Mineme 앱의 전체적인 사용자 경험이 크게 향상되었습니다.

일관성: 전체 앱에서 통일된 색상과 스타일을 적용함으로써, 사용자는 더욱 일관된 사용자 경험을 누리게 되었습니다.
유연성: 시스템 설정에 따라 자동으로 다크 모드나 라이트 모드를 전환하는 기능은 사용자의 개인 취향과 환경에 맞게 UI를 최적화합니다.
개발 효율성: 하드 코딩된 스타일 대신 테마를 활용하니, 디자인 변경이나 추가 구현 시 훨씬 더 빠르고 효율적으로 작업할 수 있게 되었습니다.

이러한 테마 적용을 통해 한단계 사용자 친화적이고, 개발자 친화적인 환경을 구축된 것 같습니다. 지금까지 읽어주셔서 감사합니다!

profile
Android Developer

0개의 댓글