Mastodon Server-driven theming in Jetpack Compose • Adam Bennett

Server-driven theming in Jetpack Compose

Featured in jetc.dev.

Hideous? Yes. Interesting? Also yes.

At Cuvva, we’ve been playing with Jetpack Compose for a few months and we’ve just recently started porting across our design system. This has lead us to think a lot about design systems in general - how can we streamline the process? How can we ensure that it’s always up to date?

We discussed a couple of different solutions, and the most likely one that we’ll go with for now is some form of custom plugin for Figma. But we were wondering - how easy would it be to create a server-driven theme for your app?

Building a Proof-of-Concept

Turns out it’s pretty easy. I’ve put an example up here - it’s by no means a stunning technical achievement but it’s certainly interesting to consider the implications. In a nutshell, this project contains a simple Ktor server which serves a set of colours for a theme. The Android app then consumes this API, updating its theme to all consumers.

Building the Backend

I won’t go into too much detail and this could just as easily have been served using MockWebServer, or stubbed completely, but it seemed like a fun excuse to use Ktor. First of all we define a data class to represent how colours work in our design system, and then we largely steal the colours from the JetChat Compose sample:

data class Colors(
    val primary: Color,
    val primaryVariant: Color,
    val onPrimary: Color,
    val secondary: Color,
    val onSecondary: Color,
    val surface: Color,
    val onSurface: Color,
    val onBackground: Color,
    val error: Color,
    val onError: Color,
)

val DesignSystemLightPalette = Colors(
    primary = Blue500,
    primaryVariant = Blue800,
    onPrimary = Color.WHITE,
    secondary = Yellow700,
    onSecondary = Color.BLACK,
    surface = Yellow700,
    onSurface = Color.BLACK,
    onBackground = Color.BLACK,
    error = Red800,
    onError = Color.WHITE
)

It would make an awful lot of sense to share these models in a Kotlin Multiplatform project, but I decided it was a little out of scope this time. Perhaps one day.

Then we serve this model:

fun main(args: Array<String>): Unit = EngineMain.main(args)

fun Application.module() {
    install(ContentNegotiation) {
        gson {
        }
    }

    routing {

        get("/theme") {
            call.respond(DesignSystemLightPalette)
        }
    }
}

So now we can access a JSON version of our design system theme at http://0.0.0.0:8080/theme/, or http://10.0.2.2:8080/theme/ if you’re consuming the API in the emulator - I’ve left both options available in the demo repo if you want to try it out.

It’s really as simple as that - Ktor is wonderful for rapid prototyping.

Building the Frontend

Next, we build a custom design system theme in Jetpack Compose for our project, matching the colour definitions defined on the backend. JetSnack has a really clear example of how this is done. Again we define a class which holds our colours, but we make it mutable so that we can update it later:

class DesignSystemColors(
    primary: Color,
    primaryVariant: Color,
    // etc
) {
    var primary: Color by mutableStateOf(primary)
        private set
    var primaryVariant: Color by mutableStateOf(primaryVariant)
        private set
    // etc

    fun update(other: DesignSystemColors) {
        primary = other.primary
        primaryVariant = other.primaryVariant
        // etc
    }
}

Next comes the clever stuff that makes this actually work, and allows mutation of the globally available colours to propagate through the UI. We create an Ambient which holds our colours:

private val AmbientDesignSystemColors = staticAmbientOf<DesignSystemColors> {
    error("No ColorPalette provided")
}

And then we tell Android how to actually provide this value:

@Composable
fun ProvideDesignSystemColors(
    colors: DesignSystemColors,
    content: @Composable () -> Unit
) {
    val colorPalette = remember { colors }
    colorPalette.update(colors)
    Providers(AmbientDesignSystemColors provides colorPalette, children = content)
}

Lastly, we then create our actual theme, which consumes these colours, and we make it available globally through an object:

@Composable
fun DesignSystemTheme(content: @Composable () -> Unit) {
    ProvideDesignSystemColors(LightColorPalette) {
        MaterialTheme(
            colors = debugColors(),
            typography = typography,
            shapes = shapes,
            content = content
        )
    }
}

object DesignSystemTheme {
    @Composable
    val colors: DesignSystemColors
        get() = AmbientDesignSystemColors.current
}

There’s a couple of things here that I should clarify. We’re overriding the colours for MaterialTheme to something obnoxious, making it extremely obvious when someone is using a non-design system component + theme. This helps us debug things very quickly that would otherwise be a huge pain to track down. This is also lifted from the JetSnack example and a rather good idea for those who build custom design systems to replace that built-in to Compose.

The colours that we then specify as part of our design system are then provided through this Ambient system, which can be thought of as a service locator for the UI which allows anything wrapped in an Ambient provider (in this case, DesignSystemTheme) to access values. Because our color object comprises of many MutableState objects, Compose recognises any changes and propagates this to any nodes of the UI tree which are affected, which in this case is basically everything.

Finally, there’s a simple Retrofit setup for fetching and parsing the new colours from the backend on a button push. This ends up being a simple suspend function which loads the data from the backend, and this is then shared through a StateFlow via a ViewModel, which eventually ends up grabbing the current DesignSystemTheme.colors and calling update.

And that’s it - it’s really very simple. Of course, you could use this for a theme switcher in-app or any other way of changing theme too. But I think the ability to drive this from the backend is most interesting.

Looking ahead

Whilst this demo is pretty simple, I think there’s a lot of untapped potential here. There’s really no reason, once a design system has been established, that you can’t provide many things dynamically from the server. Here I’ve provided colours to prove a point, but we could pretty easily specify typography (sizes, font weight, letter spacing), dimensions (although I don’t think this is particularly wise) and shapes if you really wanted. Iconography is another easy and obvious win here, I think.

Rather than your design team having to notify you that they’ve tweaked a colour or the typography that you use for headers - wouldn’t it be great if it was loaded by your device at runtime? A typical usecase for design system changes is a company rebrand, where you might have to maintain both design systems and feature flag them. Server-driven theming would eliminate that. You’d probably want to store these offline too but this shouldn’t be a problem.

One final note

If you found this interesting, I’m giving a talk on the 24th of November at Mobile Notts about how Cuvva is approaching the migration of their design system to Jetpack Compose. I would love to see you there.

Also, we’re hiring.

comments powered by Disqus