Refining Compose API for design systems
Yury on 2024-02-12
Refining Compose API for design systems

Jetpack Compose both makes it easier and promotes usage of an internal design system by creating custom Compose components. But how should we build these components?
In this article, we will take a look at possible implementations of a design component, explore their API verbosity and extensibility, and how we can find a balance between these characteristics to make Compose components both easy to use, enforce design system guidelines and extendable on demand. Let’s get started!
Design System and Compose
A design system is a comprehensive set of guidelines, components, and rules that help to build cohesive and consistent user interfaces. The most common design system for Android developers is Material Design 3 which is available for Compose too. Custom design systems might be built on top of existing systems like Material Design or can be created from scratch. Compose provides all the tools that are required for this process.
Design System NavigationBar
Let’s take a look at NavigationBar
.
A usual NavigationBar
that we can see almost in every app. The design system in this case defines the following properties:
- Margin between the component borders and the content.
- Margin between the left/middle/right content.
- Preferred style of the left/right icons.
- Text style of the title.
Compose implementation of NavigationBar
is pretty straightforward.
@Composable fun NavigationBar( title: String, modifier: Modifier = Modifier, leftButton: IconButton? = null, rightButton: IconButton? = null, ) { Row(modifier) { ... } } @Immutable data class IconButton( val icon: Painter, val onClick: () -> Unit, )
An API that allows only particular use of NavigationBar
is a restrictive API. Such API ensures that developers will be able to use the component only in the predefined way, leaving no space for possible mistakes and inconsistency. Great at first sight, but has a major restriction — missing extensibility.
As soon as we continue making our apps bigger, we will eventually face more and more specific cases that are necessary for particular screens. The example above is NavigationBar
variant with a profile logo instead of a text. Let’s expand restrictive API to support this case too.
@Composable fun NavigationBar( - title: String, + content: Content, modifier: Modifier = Modifier, leftButton: IconButton? = null, rightButton: IconButton? = null, ) { Row(modifier) { ... } } @Immutable data class IconButton( val icon: Painter, val onClick: () -> Unit, ) +@Immutable sealed interface Content { + data class Title(val text: String): Content + data object ProfileLogo: Content +}
The problem appears to be that we need to change and adopt both API and implementation of NavigationBar
for each new case. Can we do something to not make the sealed interface Content
deal with a dozen possible variants?
Relaxed API
To make it relaxed we should make NavigationBar
accept any type of child content. For this case Compose has Slot API — ability to accept @Composable
lambda that will produce the required content. In this case NavigationBar
can use it for the left/middle/right content.
@Composable fun NavigationBar( content: @Composable () -> Unit, modifier: Modifier = Modifier, leftButton: (@Composable () -> Unit)? = null, rightButton: (@Composable () -> Unit)? = null, ) { Row(modifier) { } } val IconSize = 40.dp
Now we can provide any content that we need, may it be a title or a logo.
@Composable fun NavigationBarSample() { NavigationBar( content = { Text(text = "Title", style = MaterialTheme.typography.headlineSmall) // or Icon(painterResource(R.drawable.profile_logo), null) }, leftButton = { Icon( painter = painterResource(R.drawable.close), contentDescription = null, modifier = Modifier .size(IconSize) .clickable { TODO() } ) }, rightButton = { Icon( painter = painterResource(R.drawable.menu), contentDescription = null, modifier = Modifier .size(IconSize) .clickable { TODO() } ) }, ) }
Now we have a relaxed API that allows developers to put any other Composable functions into it. Despite being more flexible it has obvious disadvantages:
- we need more code to use it;
- it is easier to mess up styles.
To overcome the issues we can introduce NavigationBarTitle
and NavigationBarIconButton
Composable functions.
@Composable fun NavigationBarTitle( title: String, modifier: Modifier = Modifier, ) { Text(text = title, style = MaterialTheme.typography.headlineSmall, modifier) } @Composable fun NavigationBarIconButton( icon: Painter, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Icon( painter = painterResource(R.drawable.menu), contentDescription = null, modifier = modifier .size(IconSize) .clickable { onClick() } ) } private val IconSize = 40.dp
While fixing one problem we are introducing another one — global scope pollution. Now it is possible to:
- use
NavigationBarTitle
even withoutNavigationBar
which should be avoided; - have
NavigationBarTitle
in autocomplete suggestions everywhere together withNavigationBar
.
While investigating what we can do with it I tried to use Material Design 3 Compose implementation as a baseline. Let’s take a look at ExposedDropdownMenuBox
sample usage:
@Composable fun ExposedDropdownMenuBoxExample() { ExposedDropdownMenuBox( ... ) { TextField( ... trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.textFieldColors(), ) ... } }
In the example above I am following official samples and using ExposedDropdownMenuDefaults
to provide styling options for TextField
. To render TextField.trailingIcon
we can use ExposedDropdownMenuDefaults.TrailingIcon
, which is a simple Icon
with a predefined size, vector icon and rotation.
object ExposedDropdownMenuDefaults { @Composable fun TrailingIcon(expanded: Boolean) { Icon( Icons.Filled.ArrowDropDown, null, Modifier.rotate(if (expanded) 180f else 0f) ) } }
Creating Defaults
for components has the following benefits:
- Discoverability. All values (styles, sizes, paddings, Composable functions) now are in a single place. It is easy to find what you need by just typing
ComponentNameDefaults
. in IDE. - Scoping.
TrailingIcon
ofExposedDropdownMenu
won’t be suggested by IDE everywhere. - Consistent styling. We have a single place to update styles across the whole app.
+@Stable object NavigationBarDefaults { - @Composable fun NavigationBarTitle( + @Composable fun Title( title: String, modifier: Modifier = Modifier, ) { Text(text = title, style = MaterialTheme.typography.headlineSmall, modifier) } - @Composable fun NavigationBarIconButton( + @Composable fun IconButton( icon: Painter, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Icon( painter = painterResource(R.drawable.menu), contentDescription = null, modifier = modifier .size(IconSize) .clickable { onClick() } ) } private val IconSize = 40.dp +}
NOTE: Mark Defaults
object with @Stable
annotation. All functions of object
have this
as the last parameter of the function after compilation — @fun NavigationBarTitle(title: String, modifier: Modifier = Modifier, $this: NavigationBarDefaults = NavigationBarDefaults)
. Marking NavigationBarDefaults
with @Stable
we make this Composable function skippable.
Now we can simplify the previous example:
@Composable fun NavigationBarSample() { NavigationBar( content = { NavigationBarDefaults.Title("Title") }, leftButton = { NavigationBarDefaults.IconButton( painter = painterResource(R.drawable.close), onClick = { TODO() }, ) }, rightButton = { NavigationBarDefaults.IconButton( painter = painterResource(R.drawable.menu), onClick = { TODO() }, ) }, ) }
Scoping
If you have ever used default layout Composable functions like Row
, you might notice that they provide Slot API with the corresponding Scope
interface — content: @Composable RowScope.() -> Unit
. The scope provides additional functionality like custom Modifiers that can be applied only to this particular layout — Modifier.weight
, Modifier.align
, etc.
This technique can be also useful for this case too. Constantly using NavigationBarDefaults
might not be that convenient. We can provide NavigationBarDefaults
in the same way as Row
provides RowScope
.
@Composable fun NavigationBar( - content: @Composable () -> Unit, + content: @Composable NavigationBarDefaults.() -> Unit, modifier: Modifier = Modifier, - leftButton: (@Composable () -> Unit)? = null, + leftButton: (@Composable NavigationBarDefaults.() -> Unit)? = null, - rightButton: (@Composable () -> Unit)? = null, + rightButton: (@Composable NavigationBarDefaults.() -> Unit)? = null, ) { Row(modifier) { } }
Now NavigationBarDefaults
will be automatically available within this
scope in the lambdas and we can easily remove NavigationBarDefaults
from them.
@Composable fun NavigationBarSample() { NavigationBar( - content = { NavigationBarDefaults.Title("Title") }, + content = { Title("Title") }, leftButton = { - NavigationBarDefaults.IconButton( + IconButton( painter = painterResource(R.drawable.close), onClick = { TODO() }, ) }, rightButton = { - NavigationBarDefaults.IconButton( + IconButton( painter = painterResource(R.drawable.menu), onClick = { TODO() }, ) }, ) }
Using Scoping
also gives us the ability to create config-dependent defaults. In this design system, we have a Button
component that might be of 2 sizes: compact and regular.
Depending on configuration we have a different size of the icon and the text. From the first sight, implementation of it for ButtonDefaults
is straightforward.
enum class ButtonSize { Compact, Regular } @Stable object ButtonDefaults { @Composable fun Text( text: String, buttonSize: ButtonSize, ) { val style = when (buttonSize) { Compact -> typography.MaterialTheme.typography.bodySmall Regular -> typography.MaterialTheme.typography.bodyMedium } Text(text = text, style = style) } } @Composable fun Button( content: @Composable ButtonDefaults.() -> Unit, buttonSize: ButtonSize, modifier: Modifier = Modifier, ) { val padding = when (buttonSize) { Compact -> PaddingValues(horizontal = 8.dp, vertical = 4.dp) Regular -> PaddingValues(horizontal = 8.dp, vertical = 8.dp) } Box(modifier.padding(padding)) { ... } } @Composable fun ButtonSample() { Button( content = { Text("Hello", ButtonSize.Regular) }, buttonSize = ButtonSize.Regular, ) }
The issue with this code is that we need to specify ButtonSize
twice: for Button
and for Text
. To solve this problem we can get closer to the implementation of Row.content
parameter. We can convert ButtonDefaults
into a regular class, rename it to ButtonScope
and pass ButtonSize
directly into it.
-@Stable object ButtonDefaults { +@Stable class ButtonScope(private val buttonSize: ButtonSize) { @Composable fun Text( text: String, - buttonSize: ButtonSize, ) { // Use buttonSize provided via constructor val style = when (buttonSize) { Compact -> typography.MaterialTheme.typography.bodySmall Regular -> typography.MaterialTheme.typography.bodyMedium } Text(text = text, style = style) } } @Composable fun Button( - content: @Composable ButtonDefaults.() -> Unit, + content: @Composable ButtonScope.() -> Unit, buttonSize: ButtonSize, modifier: Modifier = Modifier, ) { val padding = when (buttonSize) { Compact -> PaddingValues(horizontal = 8.dp, vertical = 4.dp) Regular -> PaddingValues(horizontal = 8.dp, vertical = 8.dp) } + val scope = remember(buttonSize) { ButtonScope(buttonSize) } Box(modifier.padding(padding)) { ... + with(scope) { content() } ... } } @Composable fun ButtonSample() { Button( - content = { Text("Hello", ButtonSize.Regular) }, + content = { Text("Hello") }, buttonSize = ButtonSize.Regular, ) }
Now we can specify ButtonSize
only once when we pass it to Button
. Scoped lambdas will use Text
Composable function with proper size automatically.
Additionally, we can go further and implement separate scopes for each Slot of the component. In the case of NavigationBar
we do not want to provide the ability to use IconButton
in content instead of leftButton
or rightButton
. To overcome this we will introduce NavigationBarContentScope
and NavigationBarButtonScope
.
@Stable class NavigationBarContentScope { @Composable fun Title( title: String, modifier: Modifier = Modifier, ) { ... } @Composable fun ProfileLogo( title: String, modifier: Modifier = Modifier, ) { ... } } @Stable class NavigationBarButtonScope { @Composable fun IconButton( icon: Painter, onClick: () -> Unit, modifier: Modifier = Modifier, ) { ... } } @Composable fun NavigationBar( content: @Composable NavigationBarContentScope.() -> Unit, modifier: Modifier = Modifier, leftButton: (@Composable NavigationBarButtonScope.() -> Unit)? = null, rightButton: (@Composable NavigationBarButtonScope.() -> Unit)? = null, ) { val contentScope = remember { NavigationBarContentScope() } val buttonScope = remember { NavigationBarButtonScope() } Row(modifier) { ... with(contentScope) { content() } with(buttonScope) { rightButton() } ... } }
Now it is impossible to use IconButton
for content
and ProfileLogo
for leftButton
, narrowing down possible use cases closer to design system-defined rules.
Results
As a result, we have walked through different implementations of the component, starting from restricted API, replacing it with relaxed API and refining it with Defaults
and Scope
.
By using relaxed with Defaults API we have overcome the following issues:
- Extensibility. We made the component extensible compared to restricted API. We do not need to define all possible cases in primitives or sealed classes.
- Global scope pollution. We made content suggestions scoped to the single Slot API lambda. We do not pollute global scope with very specific Composable functions like
NavigationBarTitle
making them available everywhere. - Parameter duplication. We made it possible to pass a styling parameter once in component Composable function and apply it to content suggestions too.