Refining Compose API for design systems

Yury
Bumble Tech
Published in
9 min readFeb 21, 2024

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:

  1. Margin between the component borders and the content.
  2. Margin between the left/middle/right content.
  3. Preferred style of the left/right icons.
  4. Text style of the title.

Restrictive API

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:

  1. we need more code to use it;
  2. it is easier to mess up styles.

In the example above we have to explicitly use MaterialTheme.typography.headlineSmall for Text and IconSize constant for Icon to meet styling requirements for the component which are easy to miss. Also, we are making implementation details of NavigationBar public to API consumers.

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:

  1. use NavigationBarTitle even without NavigationBar which should be avoided;
  2. have NavigationBarTitle in autocomplete suggestions everywhere together with NavigationBar.

Defaults

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:

  1. 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.
  2. Scoping. TrailingIcon of ExposedDropdownMenu won’t be suggested by IDE everywhere.
  3. Consistent styling. We have a single place to update styles across the whole app.

To apply the same approach to NavigationBar we need to simply surround NavigationBarTitle with Kotlin object and rename function to remove redundant duplication in the name.

+@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.

Compact button
Regular button

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:

  1. Extensibility. We made the component extensible compared to restricted API. We do not need to define all possible cases in primitives or sealed classes.
  2. 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.
  3. Parameter duplication. We made it possible to pass a styling parameter once in component Composable function and apply it to content suggestions too.

Now you know different approaches to designing component API, so you can pick the most suitable one for your case. I hope this article will help you with that. Feel free to share your thoughts or ask questions in the comments section! Thank you!

--

--