Creating Restricted Scope Component Templates in Compose
How to make certain components available only within specif scopes.
Design systems can be categorized into two types: general design systems and product-specific design systems.
A general design system focuses on scalability, while a product design system emphasizes consistency. Both are designed to simplify workflows and improve efficiency.
In my opinion, the Material Design system is similar to general design systems because it doesn’t impose strict rules on you, except for certain components like switches, buttons, and a few others.
As your product expands and more services are launched, maintaining consistency across these services becomes essential. This is when the value of a product design system becomes crucial.
I’ve been considering how Compose could better support a product design system.
In general, we recommend using the Slot API in Compose.
Here’s an example of using the Slot API with a AlertDialog.
@Composable
expect fun AlertDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
dismissButton: @Composable (() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
shape: Shape = AlertDialogDefaults.shape,
containerColor: Color = AlertDialogDefaults.containerColor,
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
titleContentColor: Color = AlertDialogDefaults.titleContentColor,
textContentColor: Color = AlertDialogDefaults.textContentColor,
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties()
)
An AlertDialog can be seen as a combination of slots, such as a title, icon, text, and buttons.
Another example is a ListItem, which is composed of various components.
@Composable
fun ListItem(
headlineContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
overlineContent: @Composable (() -> Unit)? = null,
supportingContent: @Composable (() -> Unit)? = null,
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation,
) {
This API is based on a general design system that supports extensibility, but our goal is to create an API tailored for a product design system.
In a product design system, we aim to fill slots with well-defined combinations of variations.
As a result, we want to define several variants for each slot and make them reusable. At first glance, it might seem like we could maintain consistency by parameterizing it as a data class instead of using a slot. However, since many of Compose’s features are supported through wrapping Compose functions, this approach makes it harder to extend Compose.
@Composable
fun ListItem(
headlineContent: String,
modifier: Modifier = Modifier,
overlineContent: String,
supportingContent: String,
leadingContent: String,
trailingContent: String,
) {
One example of this is BadgedBox, which needs to be wrapped around certain composables.
BottomNavigationItem(
icon = {
BadgedBox(
badge = {
Badge {
val badgeNumber = "8"
Text(
badgeNumber,
modifier =
Modifier.semantics {
contentDescription = "$badgeNumber new notifications"
}
)
}
}
) {
Icon(Icons.Filled.Favorite, contentDescription = "Favorite")
}
},
Next, we have RowScope and ColumnScope, which allow you to use certain modifiers only within a specific scope.
interface RowScope {
@Stable
fun Modifier.weight(
@FloatRange(from = 0.0, fromInclusive = false) weight: Float,
fill: Boolean = true
): Modifier
@Stable fun Modifier.align(alignment: Alignment.Vertical): Modifier
@Stable fun Modifier.alignBy(alignmentLine: HorizontalAlignmentLine): Modifier
@Stable fun Modifier.alignByBaseline(): Modifier
@Stable fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int): Modifier
}
RowScope is a public API, while RowScopeInstance is an internal API, meaning clients cannot access RowScopeInstance. These modifiers can only be used within the RowScope.
interface RowScope
internal object RowScopeInstance : RowScope
We can follow this pattern and create something similar.
interface TitleScope {
@Composable
fun SingleLine(title: String, modifier: Modifier)
@Composable
fun TwoLine(title: String, subTitle: String, modifier: Modifier)
}
internal object TitleScopeInstance: TitleScope {
@Composable
override fun SingleLine(title: String, modifier: Modifier) { TODO("Not yet implemented") }
@Composable
override fun TwoLine(title: String, subTitle: String, modifier: Modifier) { TODO("Not yet implemented") }
}
Apps can now restrict these components to be used only within specific scopes.
@Composable
fun MyDialog(title: @Composable TitleScope.() -> Unit) {
TitleScopeInstance.title()
}
However, there is a major issue: interfaces cannot define default parameters.
It becomes very cumbersome for clients to manually enter all the parameters. If we design it this way, customers are unlikely to use our variants.
To address this, we will create a singleton object that can be accessed through the interface, but not through the instance.
class TitleScope private constructor() {
@Composable
fun SingleLine(title: String, modifier: Modifier = Modifier) { TODO("Not yet implemented") }
@Composable
fun TwoLine(title: String, subTitle: String, modifier: Modifier = Modifier) { TODO("Not yet implemented") }
companion object {
private val instance = TitleScope()
internal operator fun invoke(): TitleScope = instance
}
}
Now, the TitleScope instance is accessible to the inner app, but the app can only interact with the TitleScope interface, as TitleScope is public.
MyDialog will be modified as shown below.
@Composable
fun MyDialog(title: @Composable TitleScope.() -> Unit) {
TitleScope().title()
}
Templates just got easier! Clients can easily see what components are supported within this range, thanks to autocomplete.
@Composable
fun Screen() {
MyDialog(
title = {
SingleLine("", Modifier)
}
)
}
It is now possible to restrict these components to be used only within certain scopes.
Conclusion
By using the singleton class pattern below, we can support multiple variations, enabling us to provide a tailored API for our product design system.
class Scope private constructor() {
@Composable
fun teamplate() {}
companion object {
private val instance = Scope()
internal operator fun invoke(): Scope = instance
}
}