Building consistency at scale: Our journey with Compose Design System | by Matias Isella | The Glovo Tech Blog | Nov, 2024

As explained above, we will be focusing on the API, and based on our requirements and decisions at this point, we will be developing a Kotlin Compose API.

Theme

The Theme is the main entry point of the Design System and, at this point, it is expected in all Compose Design Systems to implement this pattern and expose a public Theme in their APIs. This pattern is built using three main components:

1. Theme Composable Function: This encapsulates all the theme properties, like colors and typography, and provides those to the corresponding composition locals in the composable tree.

@Composable
public fun CoreTheme(
colorScheme: CoreColorScheme = CoreTheme.colorScheme,
typography: CoreTypography = CoreTheme.typography,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalCoreColorScheme provides colorScheme,
LocalCoreTypography provides typography,
content = content
)
}

2. Theme Composition Locals: These allow for the static referencing of theme values in composable functions. Avoiding repeating or passing these as Composable functions parameters.

internal val LocalCoreTypography = staticCompositionLocalOf { CoreTypography() }

3. Theme Object: A singleton with the main purpose of increasing the discoverability of all the local compositions defined above.

internal object CoreTheme {

val colorScheme: CoreColorScheme
@Composable
@ReadOnlyComposable
get() = LocalCoreColorScheme.current

val typography: CoreTypography
@Composable
@ReadOnlyComposable
get() = LocalCoreTypography.current
}

For more details, find the full Theme Anatomy in the Google Docs.

For us, this pattern provides the right level of scalability and flexibility. First, it gives the capability to wrap the main Theme to override the default values. Second, it allows the creation of new values based on the requirements of each Theme while sharing the core experience.

We highly recommend the Compose Theming Codelab to understand how theming works on Android. Although it uses Material 2 instead of Material 3, the basics of overriding a Theme, accessing the composition locals, and creating your own values are compatible.

Along with the Theme, the Design System must expose the components in its API. Ultimately, the goal of this API is to match the requirements from UX while minimizing the cognitive effort required by engineers to interpret the design.

Content

Composable APIs can follow a few different variants to deal with the Content of the Component.

Option 1: Slot API

From: Compose Component API Guidelines

This type of API is the most flexible, allowing the content to be any composable, and it is the recommended approach for all Composable APIs.

For us this type of API is used publicly for Layouts or Containers. And used internally for generalizing Components within the Design System to foster reusability. Exposing solely this type of API while increasing the flexibility of the Design System, could also increase the variants of each component causing a drop in Consistency.

@Composable
private fun Avatar(
// ...
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)

Option 2: 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.

From: Refining Compose API for design systems

This Compose API doesn’t have a Composable lambda in the method signature. The Component can only be used in a few ways restricted by the function parameters.

For us this type of API is good for removing ambiguity in the handover process of a new Design, since the content is highly opinionated and pre configured to look exactly like the UX Team defined, we only need to request the minimum variable portions of the Component. In our case, under the hood, these APIs always consume a Slot based one.

@Composable
public fun Avatar(
// ...
modifier: Modifier = Modifier,
painter: Painter,
)

@Composable
public fun Avatar(
// ...
modifier: Modifier = Modifier,
text: String? = null,
)

Option 3: DSL based slots

This pattern relies on the Composable lambda receiver Type to pass a Scope with Composable members.

DSL for defining content of the component or its children should be perceived as an exception.

From: Compose Component API Guidelines

For us this type of API is used when we need to tear down long APIs that have configuration for more than one subcomponent. As a rule of Thumb we always prefer to expose a Restrictive API with overloads than using a DSL Slot. The reason for this is that there is no limitation on invoking many members of the same custom Scope, potentially causing unexpected results.

Composable
internal fun TextField(
// ...
start: @Composable (TextFieldContentDefaults.() -> Unit)? = null,
end: @Composable (TextFieldContentDefaults.() -> Unit)? = null,
// ...
)

@Stable
public object TextFieldContentDefaults {

@Composable
public fun Icon(
// ...
) {
// ...
}

@Composable
public fun Text(
// ...
) {
// ...
}
}

Option 4. Inverted Slot Api

This type of API is reserved for Components that are expected to be Decorated. This method enforces a specific pattern where decorations are added before or after the inner Component.

The key difference between using a “Inverted” Slot Api and a Slot Api, is that it is expected for the Decoration to share the same behavior as the inner Component.

@Composable
fun BasicTextField(
// ...
decorator: TextFieldDecorator? = null,
// ...
)

fun interface TextFieldDecorator {

@Composable
fun Decoration(innerTextField: @Composable () -> Unit)
}

Text

The text content is one of the most used in the API definition, as many components in a large design system will receive string content.

From an API perspective, the approach depends on your use case, and ultimately, there are two options: either expose an AnnotatedString overload or not.

@Composable
public fun Banner(
body: String,
)

@Composable
public fun Banner(
body: AnnotatedString,
)

The main difference between the two signatures is that by using AnnotatedString, your API opens the capability for overriding TextStyle attributes, potentially causing unexpected TextStyles that are not defined in the Design System Typography.

Additionally, note that without getting into the implementation details, regardless of your API, all text will fall under one of these Compose modifiers: TextStringSimpleElement or TextAnnotatedStringElement. The second one is slower than the first one. When possible, prefer to use different BasicText components, one for the AnnotatedString and one for the String.

Style

We refer to Style as those parameters or functions used in our Design System API to define the look and feel of a component. Usually, Style parameters or functions are key elements in the handover process from UX to Engineering. These should be consistent across platforms and UX tooling to produce the same output.

Regardless of the process for building styles (e.g., manual or code generation), we have identified at least three main options in the API with styleable Components.

Option 1: Closed Styles

This is the simplest approach. The main advantage is that your Style constructor API visibility ensures no new styles will be created, making it closed for extension by design. The main disadvantage is that ad hoc styles for experimentation are not possible.

@Composable
public fun Avatar(
style: AvatarStyle,
)

public enum class AvatarStyle(internal val shape: CornerBasedShape) {
SQUARE(RoundedCornerShape(64.dp)),
CIRCLE(CircleShape)
}

or

@Composable
public fun Avatar(
style: AvatarStyle,
)

public enum class AvatarStyle {
Square,
Circle,
}

Option 2: Open Styles

The main disadvantage of this option is the lack of exhaustiveness in the evaluation of styles, which can be useful for some presentation use cases. The main advantage is that it facilitates easier collaboration for consumers of the Design System and allows for simpler experimentation.

@Composable
public fun Avatar(
style: AvatarStyle,
)

public data class AvatarStyle(internal val shape: CornerBasedShape)

public data object CoreAvatarStyle {
public val Square: AvatarStyle = AvatarStyle(RoundedCornerShape(64.dp))
public val Circle: AvatarStyle = AvatarStyle(CircleShape)
}

There is no compose stability difference between Open and Closed Styles as long as they encapsulate stable parameters.

Option 3: Multiple components

The previous two approaches use parameters for styling the component. Although this is easier from a handover perspective and for experimentation, the recommended convention is to specify separate @Composable functions with different names.

Express dependencies in a granular, semantically meaningful way. Avoid grab-bag style parameters and classes, akin to ComponentStyle or ComponentConfiguration.

From: Compose Component API Guidelines

The main advantage of this approach is that the semantic meaning is clear and doesn’t need to be unwrapped. The main disadvantage is that it is harder to discover these separate functions compared to using a style parameter.

@Composable
public fun PrimaryAvatar() {

}

@Composable
public fun SecondaryAvatar() {

}

In the example above, specifying an avatar as either square or circle does not carry inherent semantic meaning in the function. On the contrary, designating an avatar as primary or secondary conveys semantic meaning, indicating their importance and intended usage in the UI.

Modifiers

Every component that emits UI should have a modifier parameter.

Based on the Compose component API guidelines, every public Composable API should expose a Modifier.

Exposing a Modifier in the API ensures flexibility and consistency due being able to allow adding functionality without actually changing the Component.

Modifiers in APIs are expected to be at a certain position in the parameters: right after the required parameters and before the first optional parameter.

Why? Required parameters indicate the contract of the component, since they have to be passed and are necessary for the component to work properly. By placing required parameters first, API clearly indicates the requirements and contract of the said component. Optional parameters represent some customisation and additional capabilities of the component, and don’t require immediate attention of the user.

Note that missing a Modifier in the API imposes several restrictions regardless of the content, such as testability or accessibility, which rely on the semantic tree to be achievable. And this semantic tree is built using the semantics Modifier.

Stability

When building a Composable library at scale, it is desirable that most components are skippable due to being small units within the UI. A Composable Design System shouldn’t impact recomposition.

From an API perspective, using Compose foundations ensures that the classes used as parameters are stable, making the components skippable. For us, most of the instability usually comes from using unstable parameters such as List or Painter. Overall, running regular Compose compiler reports to catch any regressions has been effective.

composeCompiler {
enableStrongSkippingMode = true
reportsDestination = file("build/reports/compose")
}

Note that a strong skipping mode would solve many of these caveats.

Documentation

As any API, it is expected to have good code documentation providing relevant content to Developers using the Design System. And KDoc offers several attributes that help developers understand and navigate your API more effectively.

In addition to the common @parameter and @see tags, which describes how inputs affect the component’s behavior and provide links to relevant classes or methods, the @sample tag is particularly important in a Design System API.

The @sample tag has many relevant features for us:

  1. It gives the developer an immediate example of how to use the Component.
  2. The sample is a Composable @Preview, providing the developer with an immediate preview of the Component.
  3. Samples are also compiled, offering an integration test out-of-the-box for free.
  4. Samples are included as Code Blocks when using a documentation engine.

Finally, a clear benefit of using KDocs is the ability to export this documentation for public availability by generating it in HTML and Markdown using Dokka.

Bonus: Explicit API

By using strict explicit API, we ensure consistency in the codebase since developers must follow the same conventions. Developers are forced to think about each API’s visibility. This leads to better designed APIs, build time check, and self documented code through the use of explicit visibility modifiers.

kotlin {
explicitApi()
}

In summary, these are the key points we would like to highlight:

  • Material Design: We don’t use it directly. Instead, we reuse Material components when needed, but these are internal to the Design System.
  • Legacy View System: Not supported. We focus only on Jetpack Compose and use the Design System to leverage increased adoption of Compose.
  • API Flexibility: We use different approaches depending on the required flexibility. However, we prefer to be opinionated as much as possible and expose the minimum number of parameters to prevent unexpected variations of components. In line with this, we use Closed Styles to ensure exhaustive evaluations and to make sure no use case falls through the cracks.
  • API Documentation: We heavily rely on KDoc to explain the components to Design System users, providing good code examples.
  • API Visibility: Using explicit API has been key to maintaining consistency.

This is just the first step of our journey. We are leaving the full implementation of all these APIs outside of this initial article. Diving deeper into this topic will require a few more articles, so for now, we will leave it here. Thank you for reading, and stay tuned for future updates where we’ll explore these concepts in more detail.

Leave a Reply