[Compose] Custom Theme 만들기

2

컴포즈로 Material Theme를 사용하다보면, 테마 가짓수에 고개를 갸우뚱하곤 합니다.

(약 12가지 컬러를 지원한다.)

팔레트 수가 적다보니 아래와 같은 이슈에 대응하기 어렵습니다.

  • 많은 색깔을 지원해야함.
  • 디자이너가 만든 팔레트 네임을 그대로 유지하고 싶음.
(현재 만드는 디자인 시스템은 이름도 다르고, 컬러수도 많다.)

이럴때 테마를 새로 만드는 방법도 고려해볼 수 있습니다.

왜 추천이 아닌 고려일까요?

Custom Theme를 만들었을때의 Trade Off는 바로 기본 컴포넌트에 영향을 미친다는 것입니다.
아래의 코드를 한번 보실까요?

Compose에서 제공되는 컴포넌트 중에는 MaerialTheme가 하드코딩되어있는 컴포넌트들이 많습니다.
결국 요구사항에 맞추기 위해서는 Component 내부를 복사하여 입맛에 맞게 수정하는일이 빈번해지게 됩니다.

1. Color 만들기

색깔을 지원하기 위해선 색깔을 파라미터로 가지는 Class가 필요합니다.

class PollPollColors(
//색깔을 파라미터로 입력한다.
    primary_500: Color,
    primary_300: Color,
    primary_100: Color,
    primary_050: Color,
    secondary_500: Color,
    secondary_300: Color,
    secondary_050: Color,
    gray_050: Color,
    gray_100: Color,
    gray_200: Color,
    gray_300: Color,
    gray_400: Color,
    gray_500: Color,
    gray_700: Color,
    gray_900: Color,
    isLight: Boolean
) 

Color를 그대로 사용한다면 상관이 없지만, 다크모드로 변경되었을때 Recomposition이 일어나야합니다.
그렇기에 받은 파라미터가 안에서 State로 Wrapping 되어야 합니다.

   class PollPollColors(
//색깔을 파라미터로 입력한다.
    primary_500: Color,
    primary_300: Color,
    primary_100: Color,
    primary_050: Color,
    secondary_500: Color,
    secondary_300: Color,
    secondary_050: Color,
    gray_050: Color,
    gray_100: Color,
    gray_200: Color,
    gray_300: Color,
    gray_400: Color,
    gray_500: Color,
    gray_700: Color,
    gray_900: Color,
    isLight: Boolean
) {
   var primary_500 by mutableStateOf(primary_500)
        private set

    var primary_300 by mutableStateOf(primary_300)
        private set

    var primary_100 by mutableStateOf(primary_100)
        private set

    var primary_050 by mutableStateOf(primary_050)
        private set

    var secondary_500 by mutableStateOf(secondary_500)
        private set
    var secondary_300 by mutableStateOf(secondary_300)
        private set
    var secondary_050 by mutableStateOf(secondary_050)
        private set
    var gray_050 by mutableStateOf(gray_050)
        private set
    var gray_100 by mutableStateOf(gray_100)
        private set
    var gray_200 by mutableStateOf(gray_200)
        private set
    var gray_300 by mutableStateOf(gray_300)
        private set
    var gray_400 by mutableStateOf(gray_400)
        private set

    var gray_500 by mutableStateOf(gray_500)
        private set
    var gray_700 by mutableStateOf(gray_700)
        private set
    var gray_900 by mutableStateOf(gray_900)
        private set

    var isLight by mutableStateOf(isLight)
        private set
}

색깔은 바뀔때마다 recomposition이 발생합니다.
만약 새로운 객체로 바꾸게 된다면, 모든 컴포저블이 재구성된다는 얘기인데요.
변경을 최소화하기 위해 updateColorsFrom을 사용하여 필요한 부분만 변경하도록하여 recompostion을 최소화 시킵니다.

/**지정된 ColorScheme 의 내부 값을 other 의 값으로 업데이트합니다. 
이렇게 하면 LocalColorScheme 의 값을 사용하는 모든 컴포저블을 재구성하지 않고도 ColorScheme 의 하위 집합을 효율적으로 업데이트할 수 있습니다.

ColorScheme 은 매우 광범위하고 계층 구조의 많은 고가의 컴포저블에서 사용되기 때문에 LocalColorScheme에 새 값을 제공하면 LocalColorScheme 을 사용하는 모든 LocalColorScheme 이 재구성됩니다. 
이는 테마의 한 색상에 애니메이션을 적용하는 것과 같은 경우 엄청나게 비용이 많이 듭니다. 
대신 ColorScheme 은 mutableStateOf 에 의해 내부적으로 지원되며 이 함수는 other 의 값과 일치하도록 this 의 내부 상태를 변경합니다. 

즉, 모든 변경사항은 this 의 내부 상태를 변경하고 변경된 특정 값을 읽는 컴포저블만 재구성하도록 합니다.
*/

    fun copy(
        primary_500: Color = this.primary_500,
        primary_300: Color = this.primary_300,
        primary_100: Color = this.primary_100,
        primary_050: Color = this.primary_050,
        secondary_500: Color = this.secondary_500,
        secondary_300: Color = this.secondary_300,
        secondary_050: Color = this.secondary_050,
        gray_050: Color = this.gray_050,
        gray_100: Color = this.gray_100,
        gray_200: Color = this.gray_200,
        gray_300: Color = this.gray_300,
        gray_400: Color = this.gray_400,
        gray_500: Color = this.gray_500,
        gray_700: Color = this.gray_700,
        gray_900: Color = this.gray_900,
        isLight: Boolean = this.isLight
    ) = PollPollColors(
        primary_500 = primary_500,
        primary_300 = primary_300,
        primary_100 = primary_100,
        primary_050 = primary_050,
        secondary_500 = secondary_500,
        secondary_300 = secondary_300,
        secondary_050 = secondary_050,
        gray_050 = gray_050,
        gray_100 = gray_100,
        gray_200 = gray_200,
        gray_300 = gray_300,
        gray_400 = gray_400,
        gray_500 = gray_500,
        gray_700 = gray_700,
        gray_900 = gray_900,
        isLight = false
    )

    fun updateColorsFrom(other: PollPollColors) {
        primary_500 = other.primary_500
        primary_300 = other.primary_300
        primary_100 = other.primary_100
        primary_050 = other.primary_100
        secondary_500 = other.secondary_500
        secondary_300 = other.secondary_300
        secondary_050 = other.secondary_050
        gray_050 = other.gray_050
        gray_100 = other.gray_100
        gray_200 = other.gray_200
        gray_300 = other.gray_300
        gray_400 = other.gray_400
        gray_500 = other.gray_500
        gray_700 = other.gray_700
        gray_900 = other.gray_900
        isLight = false
    }

2. Typography 구현하기

Typography의 값은 Color값처럼 변하지 않습니다.
recomposition이 일어나지 않아도 되기때문에 State()로 감쌀 이유가 없습니다.


val PollPollFamily = FontFamily(
    Font(R.font.pretendard_regular, FontWeight.Normal),
    Font(R.font.pretendard_semibold, FontWeight.SemiBold)
)


//폰트 패밀리가 폰트웨이트에 따라 알아서 선택이 된다.
data class CustomTypography(
    val heading01: TextStyle = TextStyle(
        fontFamily = PollPollFamily,
        fontWeight = FontWeight.SemiBold,
        lineHeight = 38.sp,
        fontSize = 28.sp
    ),
    val heading02: TextStyle = TextStyle(
        fontFamily = PollPollFamily,
        fontWeight = FontWeight.SemiBold,
        lineHeight = 32.sp,
        fontSize = 24.sp
    ),

3. 그외의 것..

Space라던지, Shapes를 정의할 수도 있습니다.
TypoGraphy와 같이 변경되지 않기에 dataclass를 정의 해서 사용하기도하고,

data class CustomSpaces(
    val small: Dp = 4.dp,
    val medium: Dp = 8.dp,
    val large: Dp = 16.dp,
    val extraLarge: Dp = 40.dp
)

기존 MaterialTheme에 있는것을 활용하고 싶다면, 객체를 재활용해도 좋습니다.

    val Shapes = Shapes(
        small = RoundedCornerShape(Dimens.SpacingXXS),
        medium = RoundedCornerShape(Dimens.SpacingXXS),
        large = RoundedCornerShape(Dimens.SpacingXS)
    )

4. 테마 만들기

지금까지 만든 모든 테마의 클래스들과 객체들은 CompositionLocalProvider를 통해 제공됩니다.

@Composable
fun PollPollTheme(
    spaces: CustomSpaces = PollPollTheme.spaces,
    typography: CustomTypography = PollPollTheme.typography,
    colors: PollPollColors = PollPollTheme.colors,
    darkColors: PollPollColors? = null,
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val currentColor = remember { if (darkColors != null && darkTheme) darkColors else colors }
    
    //변경에 recomposition이 최소화되기위해 부분적인 업데이트가 필요하다.
    val rememberedColors = remember { currentColor.copy() }.apply { updateColorsFrom(currentColor) }


    CompositionLocalProvider(
        LocalColors provides rememberedColors,
        LocalSpaces provides spaces,
        LocalTypography provides typography
    ) {
    //Typography의 TextStyle은 조금 다르게 선언된다.
        ProvideTextStyle(typography.heading05, content = content)
    }
}


val LocalSpaces = staticCompositionLocalOf { CustomSpaces() }
val LocalColors = staticCompositionLocalOf { lightColors() }
val LocalTypography = staticCompositionLocalOf { CustomTypography() }

테마 값 호출하기

CompositionLocalProvider로 주입된 애들은 LocalSpaces.currentLocalColors.current등을 통해 값을 가져올 수 있습니다.
이를 좀더 쉽게 액세스 할수 있도록 object로 감쌉니다.

참고로 @ReadOnlyComposable 컴포저블함수를 읽기에만 최적화되도록 만들어주는 어노테이션입니다.

object PollPollTheme {
    val colors: PollPollColors
        @Composable 
        @ReadOnlyComposable
        get() = LocalColors.current

    val typography: CustomTypography
        @Composable 
        @ReadOnlyComposable
        get() = LocalTypography.current

    val spaces: CustomSpaces
        @Composable 
        @ReadOnlyComposable
        get() = LocalSpaces.current
}

꽤나 큰 프로젝트에는 어쩔수 없이 Custom Theme를 만들어야 할것 같습니다.

감사합니다! <3


참고

Jetpack Compose에서 맞춤 테마 빌드

Jetpack Compose에서 맞춤 테마 만들기

profile
쉽게 가르칠수 있도록 노력하자

0개의 댓글