How to build a custom design system with Jetpack compose

Alireza Fard
ProAndroidDev
Published in
6 min readMar 15, 2024

--

Image by Balázs Kétyi https://unsplash.com/photos/silver-imac-displaying-color-gradient-_x335IZXxfc

I will share my experience building a custom design system in this article.

Because all of the technology has developed with Jetpack Compose it means you can use it on any platform that you want and of course, Jetpack Compose is supported :))

Firstly, we need to learn what a design system is, for sure this is a short version of a design system because it’s a whole other topic, here we just take a quick look at the fundamentals.

The purpose of a Design System is that the look and feel of a product from the start to the end should be the same.

A design system components contain:

  • Component Library.
  • Pattern Library.
  • Brand Style Guide.
  • Brand Values.
  • Design Principles.
  • Icon Library.
  • Content Guidelines.
  • Accessibility Guidelines.

So, in a Jetpack Compose project usually, we use MaterialTheme or MaterialTheme3, what if we do not want to use Material naming or convention, or we want to customize some part of that, for these cases we need to learn how we can define a design system.

For the first step we have to define a holder to hold all our configurations, we call it the “AwesomeTheme”

object AwesomeTheme {

val colors: AwesomeColor
@Composable
@ReadOnlyComposable
get() = LocalColors.current

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

val dimensions: AwesomeDimension
@Composable
@ReadOnlyComposable
get() = LocalDimensions.current

val radius: AwesomeRadius
@Composable
@ReadOnlyComposable
get() = LocalRadius.current

val elevation: AwesomeElevation
@Composable
@ReadOnlyComposable
get() = LocalElevation.current

val icons: AwesomeIcon
@Composable
@ReadOnlyComposable
get() = LocaleIcon.current
}

Then we need a composable function to use the values of the AwesomeTheme object

@Composable
fun AwesomeTheme(
colors: AwesomeColor = AwesomeTheme.colors,
typography: AwesomeTypography = AwesomeTheme.typography,
dimensions: AwesomeDimension = AwesomeTheme.dimensions,
elevation: AwesomeElevation = AwesomeTheme.elevation,
radius: AwesomeRadius = AwesomeTheme.radius,
icons: AwesomeIcon = AwesomeTheme.icons,
content: @Composable () -> Unit,
) {
val rememberedColors = remember { colors.copy() }.apply { updateColors(colors) }
CompositionLocalProvider(
LocalColors provides rememberedColors,
LocalDimensions provides dimensions,
LocalTypography provides typography,
LocalElevation provides elevation,
LocaleIcon provides icons,
LocalIndication provides rememberRipple(),
) {
content()
}
}

Perfect!.

Here, we used CompositionLocalProvider,

CompositionLocalProvider in Jetpack Compose is a mechanism that allows you to provide a reference to an object from a higher level in the UI tree, making it accessible to any child within that subtree. It enables the implementation of the Provider pattern in Jetpack Compose, where values can be accessed globally without the need to pass them down the tree explicitly. By using CompositionLocalProvider, you can set values that are available to all descendants within a specific subtree, simplifying the management of shared data in your UI components

We defined all properties of our theme, now it’s time to implement details of them.

class AwesomeColor(
primary: Color,
onPrimary: Color,
error: Color,
onError: Color,
success: Color,
onSuccess: Color,
warning: Color,
onWarning: Color,
background: Color,
onBackground: Color,
surface: Color,
onSurface: Color,
outline: Color,
isLight: Boolean,
) {
var primary by mutableStateOf(primary)
private set

var onPrimary by mutableStateOf(onPrimary)
private set

var error by mutableStateOf(error)
private set

var onError by mutableStateOf(onError)
private set

var success by mutableStateOf(success)
private set

var onSuccess by mutableStateOf(onSuccess)
private set

var warning by mutableStateOf(warning)
private set

var onWarning by mutableStateOf(onWarning)
private set

var background by mutableStateOf(background)
private set

var onBackground by mutableStateOf(onBackground)
private set

var surface by mutableStateOf(surface)
private set

var onSurface by mutableStateOf(onSurface)
private set

var outline by mutableStateOf(outline)
private set

var isLight by mutableStateOf(isLight)
internal set

fun copy(
primary: Color = this.primary,
onPrimary: Color = this.onPrimary,
error: Color = this.error,
onError: Color = this.onError,
success: Color = this.success,
onSuccess: Color = this.onSuccess,
warning: Color = this.warning,
onWarning: Color = this.onWarning,
background: Color = this.background,
onBackground: Color = this.onBackground,
surface: Color = this.surface,
onSurface: Color = this.onSurface,
outline: Color = this.outline,
isLight: Boolean = this.isLight,
): AwesomeColor = AwesomeColor(
primary,
onPrimary,
error,
onError,
success,
onSuccess,
warning,
onWarning,
background,
onBackground,
surface,
onSurface,
outline,
isLight,
)
}

Now we have all the color properties of our theme, and if we have multiple instances of the color class, we can have multiple color themes.

To improve the passing theme, we need to pass just a new instance and it should replace new values with old values

class AwesomeColor {

...

fun updateColors(other: AwesomeColor) {
primary = other.primary
onPrimary = other.onPrimary
error = other.error
onError = other.onError
success = other.success
onSuccess = other.onSuccess
warning = other.warning
onWarning = other.onWarning
background = other.background
onBackground = other.onBackground
surface = other.surface
onSurface = other.onSurface
outline = other.outline
isLight = other.isLight
}

...
}

It’s great, isn’t it?

To complete our theme we need to implement typography, dimensions, radius, elevation, and icons.


class AwesomeTypography {
val h1: TextStyle
@Composable
get() = TextStyle(
fontFamily = InterFont,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
lineHeight = 38.sp,
)
val h2: TextStyle
@Composable
get() = TextStyle(
fontFamily = InterFont,
fontSize = 24.ssp,
fontWeight = FontWeight.Bold,
lineHeight = 32.ssp,
)
val h3: TextStyle
@Composable
get() = TextStyle(
fontFamily = InterFont,
fontSize = 22.ssp,
fontWeight = FontWeight.Bold,
lineHeight = 30.ssp,
)
...
}

internal val LocalTypography = staticCompositionLocalOf { AwesomeTypography() }

There is a small difference between color and typography, for the typography we defined a new instance of AwesomeTypography in a “staticCompositionLocalOf” variable

staticCompositionLocalOf is an API in Jetpack Compose that is used to create a CompositionLocal. This API is specifically designed to handle cases where the value in the CompositionLocal is not expected to change frequently. When using staticCompositionLocalOf, changing the value will trigger the entire content lambda where the CompositionLocal is provided to be recomposed, instead of just the places where the value is read. This API is recommended for scenarios where the value in the CompositionLocal is constant and unlikely to change often, as it helps improve performance by minimizing unnecessary recompositions

Next is the AwesomeDimension,

class AwesomeDimension {
val spacing0: Dp
@Composable
get() = 0.dp
val spacing1: Dp
@Composable
get() = 4.dp
val spacing2: Dp
@Composable
get() = 8.dp
val spacing3: Dp
@Composable
get() = 12.dp
val spacing4: Dp
@Composable
get() = 16.dp
val spacing5: Dp
@Composable
get() = 20.dp
...
}

internal val LocalDimensions = staticCompositionLocalOf { AwesomeDimension() }

We can define spacing based on whatever metric we want, in this case, I preferred using values to multiple of 4, which means we have spacing1 with the value of 4, and with a little mathematical calculation, we can guess if we need 16 dp space we have to use spacing4.

The AwesomeRadius and AwesomeElevation are the same as AwesomeDimension,

For padding and margin, we can use AwesomeDimension, however, for corner radius, we can use AwesomeRadius, and for shadow and elevation effect we use AwesomeElevation.

We will get to the usage of AwesomeTheme.

The last part is AwesomeIcon, To have a consistent icon set across the application avoid duplication, and maintain the icon set for the future, we define all icons in one place.


class AwesomeIcon {

val direction: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_direction_24)

val feedback: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_feedback_24)

val coin: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_coin_24)

val play: ImageVector
@Composable
get() = ImageVector.vectorResource(id = R.drawable.ic_play_24)
...
...
...
}

internal val LocaleIcon = staticCompositionLocalOf { AwesomeIcon() }

Because we are using staticCompositionLocalOf and ImageVector the recomposition has been minimized as possible.

And at the end, we can use the Theme.

AwesomeTheme {
Home()
}

Up to here, we learned how to define a new theme, now it’s time to use the defined variables inside of our components

@Composable
fun AwesomeCheckbox(
modifier: Modifier = Modifier,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: ((Boolean) -> Unit)?,
) {
Checkbox(
checked = checked,
onCheckedChange = { onCheckedChange?.invoke(it) },
modifier = modifier,
enabled = enabled,
colors = CheckboxDefaults.colors(
checkedColor = AwesomeTheme.colors.primary,
),
)
}

In this example, we used the primary color that has been provided by the Theme, and without reassigning the color value to the component we have access to the high-level variable because we are in the same scope.

In conclusion

we learned a real-world usage of CompositionLocalProvider and staticCompositionLocalOf, with this approach we can provide high-level variables through the Compose tree and use it in the child-level component.

In addition, we covered building a custom design system with Jetpack Compose offers a powerful and flexible approach to crafting intuitive user interfaces for modern applications.

I recommend if you are interested in the design system topic read more about it, such as:

--

--