Make Restricted Scope Components with compose

Quokkaman
5 min readNov 14, 2024

--

How to make some components available only in certain scopes

Photo by Boitumelo on Unsplash

Design systems can be classified into two types: one is a general design system and the other is a product design system.

A general design system is a system for scalability, whereas a product design system is a system for consistency. Both systems are meant to make work easier.

In my opinion, the Material Design system is similar to general design systems because they didn’t force their system on you except some components like switch or buttons and so on.

As your product grows and more services are launched, you will need consistency across these services. Then, the value of a product design system has now become important.

I’ve been thinking about how Compose can better support a product design system.

In general, we recommend using the slot API in compose.

Below is an example of a slot API with a notification dialog.

@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 viewed as a combination of slots, including a title, an icon, text, and buttons.

Another example is a ListItem that is composed of each component.

@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 we want to create an API for a product design system.

In a product design system, we want to fill slots with well-defined combinations of variations.

slot for alert dialog
slot for list item

As a result, we want to define several variants for each slot and be able to reuse these variants.

At first glance, it seems like we could keep consistency by parameterizing it as a data class rather than a slot.

However, since many of Compose’s features are supported by wrapping Compose functions, this approach makes it difficult to extend Compose.

@Composable
fun ListItem(
headlineContent: String,
modifier: Modifier = Modifier,
overlineContent: String,
supportingContent: String,
leadingContent: String,
trailingContent: String,
) {

One example of these, badgedBox is need to be wrap between some 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 come RowScope and ColumnScope, which allow you to use some modifiers only within a 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, but RowScopeInstance is an internal API. Therefore, clients cannot access RowScopeInstance. These modifiers can be only called on RowScope.

interface RowScope
internal object RowScopeInstance : RowScope

We can refer to this pattern and create something like this.

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 called only in certain scopes.

@Composable
fun MyDialog(title: @Composable TitleScope.() -> Unit) {
TitleScopeInstance.title()
}
we can only call SingleLine witn TitleScope

However, there is a major problem that interfaces cannot define default parameters.

It is very cumbersome for clients to enter all the parameters manually. If we design this way, customers will not use our variants.

We will create a singleton object, which is accessible 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 access the TitleScope’s interface because TitleScope is public.

MyDialog will be changed like below

@Composable
fun MyDialog(title: @Composable TitleScope.() -> Unit) {
TitleScope().title()
}

Templates just got easier!! Clients can easily see what kind of transformations are supported in this range through this with autocomplete.


@Composable
fun Screen() {
MyDialog(
title = {
SingleLine("", Modifier)
}
)
}

It was possible to make these components only be called in certain scopes.

Conclusion

By using the single class pattern below, we can support several variations, allowing us to provide a suitable 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
}
}

--

--

No responses yet