Search code examples
androidandroid-jetpack-composeandroid-themeandroid-jetpack-compose-material3

Jetpack Compose override parts of a theme from a library with a custom theme


I'm working on creating a library for our apps in compose which is intended to hold all view components needed. This library also needs to have a custom theme. Especially for colors and fonts as each app that uses the library will have completely custom colors and fonts.

My problem arises when I want to create this theme. If I keep the theme and all color assignments in the library that works great but I need to be able to change the color for each themes variable in the client apps.

For this example lets say I want to define the different parts of the actionbar in a dialog with custom colors.

I've attempted doing this through CompositionLocalProvider which works fine as long as I keep the colors I define in the library with each color defined as below same way as the https://m3.material.io/theme-builder#/custom supplies.

val md_theme_light_primary = Color(0xFF6750A4)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)

The real crux appears now. Say that I have defined the color DialogActionBarText in my library. In each client app that uses the library is there any way to expose just the color variables defined to set them? Small example snippet below

/** Colors */
class TestColors(
    dialogActionBarColor: Color,
    isLight: Boolean
) {
    var dialogActionBarColor by mutableStateOf(dialogActionBarColor)
    var isLight by mutableStateOf(isLight)

    fun copy(
        dialogActionBarColor: Color = this.dialogActionBarColor,
        isLight: Boolean = this.isLight
    ): TestColors = TestColors(
        dialogActionBarColor,
        isLight
    )
    fun updateColorFrom(other: TestColors) {
        dialogActionBarColor = other.dialogActionBarColor
    }
}

fun getLightColors(): TestColors = TestColors(
    dialogActionBarColor = dialogActionBarLight,
    isLight = true
)

fun getDarkColors(): TestColors = TestColors(
    dialogActionBarColor = dialogActionBarDark,
    isLight = true
)

val dialogActionBarLight: Color = Color(0xFFAABBCC)
val dialogActionBarDark: Color = Color(0xFF112233)

val LocalColors = staticCompositionLocalOf{ getLightColors() }

/** Fonts */

private val myFont = FontFamily(
    Font(R.font.open_sans_regular, FontWeight.Normal)
)

data class AppTypography(
    val h1: TextStyle = TextStyle(
        fontFamily = myFont,
        fontWeight = FontWeight.Normal,
        fontSize = 24.sp
    ),
    val h2: TextStyle = TextStyle(
        fontFamily = myFont,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp
    )
)
internal val LocalTypography = staticCompositionLocalOf { AppTypography() }

/** Theme */

object TestTheme {
    val colors: TestColors
        @Composable
        @ReadOnlyComposable
        get() = LocalColors.current
    val fonts: AppColors
        @Composable
        @ReadOnlyComposable
        get() = LocalColors.current

}
@Composable
fun TestTheme(
    colors: TestColors = TestTheme.colors,
    typography: TestTypography = TestTheme.fonts
    dimensions: TestDimensions...,
    content: @Composable () -> Unit
) {

    val rememberedColors = remember { colors.copy() }.apply { updateColorFrom(colors) }
    CompositionLocalProvider(
        LocalColors provides rememberedColors,
        LocalTypography provides typography,
        LocalDimensions provides dimensions
    ) {
        content()
    }
}

What I want is to expose each color variable dialogActionBarLight and -Dark to the client app so that it's easy to set them while keeping the rest of the theme and color setup outside the client app. Same principle applies to the font which can be different in each client app so I would need to be able to expose the myFont variable.

My goal is the least amount of code I can achieve in the client apps to define the colors so just creating a duplicate theme sort of defeats the purpose of trying to separate it in the first place.

Any ideas for how to achieve this or something close to this?

Another way to put it would be how do I achieve the same result as overriding a resource value in xml from a library in an app using that library in compose?


Solution

  • I think you're on the right lines.

    You could just make your colors a data class, and expose the values that you want the client apps to be able to customise, e.g.

    data class TestColors(
        val mutableColor1: Color = Color.Red,
        val mutableColor2: Color = Color.White,
        val mutableColor3: Color = Color.Blue,
    ) {
        val immutableColor1 = Color.Red
        val immutableColor2 = Color.White
        val immutableColor3 = Color.Blue
    }
    

    Then use local composition, as you have.

    However your theme won't work like you have it, you can't access LocalSomething.current outside of the CompositionLocalProvider (before you've actually set it to something).

    You would need to refactor it something like this:

    @Composable
    fun TestTheme(
        colors: TestColors = TestColors(),
        typography: TestTypography = TestTypography(),
        dimensions: TestDimensions = TestDimensions(),
        content: @Composable () -> Unit
    ) {
    
        CompositionLocalProvider(
            LocalColors provides colors,
            LocalTypography provides typography,
            LocalDimensions provides dimensions
        ) {
            content()
        }
    }
    

    Then let the client apps simply customise the mutable properties and use your theme directly:

    @Composable
    fun ClientApp() {
        val colors = TestColors(...)
        TestTheme(colors = colors) {
            ...
        }
    }