Jetpack Compose Theme with Composition Local: Spacing, Shaping, and Status Colors

Kerry Bisset
7 min readJul 8, 2024

Is anyone else tired of typing Modifier.padding(8.dp)? With Jetpack Compose, managing UI configurations for your application's UI code can be challenging. However, using Composition Local, you can integrate these configurations into your theme, making your UI code more flexible and maintainable.

Composition Local is a powerful tool in Jetpack Compose that allows you to pass data down the composable tree without explicitly passing it through each composable. This feature is handy for managing global configurations and settings that must be accessed throughout your application.

What is Composition Local?

In Jetpack Compose, Composition Local is a mechanism that allows you to propagate data down the composable hierarchy without explicitly passing it through every composable. This makes it an ideal solution for managing global configurations or settings that need to be accessed by multiple composables within your application.

Composition Local works by providing a value at a certain level in the composable tree and then consuming it at any level below it. This propagation is implicit, meaning you don’t have to pass the value manually through each layer of the tree, which can significantly simplify your code.

Key Concepts

  • Local Provider: This is where you define the value you want to propagate. You use CompositionLocalProvider to specify the value at a particular level in the composable tree.
  • Local Consumer: This is where you access the propagated value. You use current property of a CompositionLocal to get the value at any point in the composable hierarchy.

Differences from Other State Management Approaches

Unlike other state management approaches that require you to pass data explicitly through every composable, Composition Local provides a cleaner and more efficient way to manage data that needs to be shared across many composables. It eliminates the boilerplate code associated with passing parameters down the composable tree and helps maintain a clear separation of concerns.

Benefits of Using Composition Local

  1. Centralized Configuration: Composition Local allows you to centralize the management of common configurations, such as themes, spacing, and colors, making your codebase more organized and maintainable.
  2. Reduced Boilerplate: By implicitly passing data through the composable tree, Composition Local minimizes the amount of boilerplate code, making your composables simpler and easier to read.
  3. Flexibility: You can easily override values at any level in the composable tree, providing flexibility in applying configurations and settings across your app.

Managing Spacing

One of the most common needs in UI design is managing consistent spacing throughout the application. Composition Local can centralize the definition and usage of spacing values, ensuring consistency and flexibility.

Example Code

Let’s break down the provided code example demonstrating how to manage spacing using Composition Local.

/**
* Specifies amount of spacing that should be used through the application in a non-graphic
* library specific amount.
*/
data object SpacingDefaults {
internal const val TINY = 2
internal const val EXTRA_SMALL = 4
internal const val SMALL = 8
internal const val MEDIUM = 16
internal const val LARGE = 32
internal const val DEFAULT = SMALL
}

This object, SpacingDefaults, defines a set of default spacing values in a non-graphic library-specific way. These values are specified as constants, representing different spacing sizes in pixels.

data class Spacing(
val default: Dp = SpacingDefaults.DEFAULT.dp,
val tiny: Dp = SpacingDefaults.TINY.dp,
val extraSmall: Dp = SpacingDefaults.EXTRA_SMALL.dp,
val small: Dp = SpacingDefaults.SMALL.dp,
val medium: Dp = SpacingDefaults.MEDIUM.dp,
val large: Dp = SpacingDefaults.LARGE.dp,
)

The Spacing data class defines the spacing values in density-independent pixels (Dp). It uses the defaults defined in SpacingDefaults and converts them to Dp. This class encapsulates all the spacing values that will be used throughout the application.

val LocalSpacing = compositionLocalOf { Spacing() }

LocalSpacing is a CompositionLocal that holds the current spacing values. It provides a default Spacing object, which can be overridden by CompositionLocalProvider.

typealias Theme = MaterialTheme

val Theme.spacing: Spacing
@Composable
@ReadOnlyComposable
get() = LocalSpacing.current

This extension property adds a spacing property to the MaterialTheme, allowing easy access to the current spacing values anywhere MaterialTheme is available.

@Composable
fun AppTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colors = if (!useDarkTheme) {
LightColors
} else {
DarkColors
}

CompositionLocalProvider(
LocalSpacing provides Spacing(),
) {
MaterialTheme(
colorScheme = colors,
content = content,
)
}
}

AppTheme is a composable function that sets up the application's theme. It allows you to specify whether to use a dark theme and provides the app's content. Inside AppTheme, CompositionLocalProvider provides the Spacing object to the composable tree. This makes the spacing values available to all composables within the MaterialTheme.

Using the Defined Spacing

Let’s look how you can use these defined spacing values in your composables:

@Composable
fun MyScreen() {
Column(
modifier = Modifier
.padding(MaterialTheme.spacing.medium)
// Or if you typealias .padding(Theme.spacing.medium)
.fillMaxSize()
) {
Text("Hello World!", modifier = Modifier.padding(MaterialTheme.spacing.small))
Spacer(modifier = Modifier.height(MaterialTheme.spacing.large))
Button(onClick = { /*TODO*/ }) {
Text("Click Me")
}
}
}

Extending the Modifier for Convenience

Another useful implementation is to add extension functions to the Modifier to simplify applying common spacing values. This approach reduces the amount of repetitive code and promotes consistency across the application. However, your development team must know that the extension function exists.

Adding Extension Functions

Let’s add some extension functions to the Modifier to apply padding based on our spacing values.

@Composable
fun Modifier.mediumSpacing(applyVertical: Boolean = true, applyHorizontal : Boolean = true): Modifier =
padding(
top = if (applyVertical) Theme.spacing.medium else 0.dp,
bottom = if (applyVertical) Theme.spacing.medium else 0.dp,
start = if (applyHorizontal) Theme.spacing.medium else 0.dp,
end = if (applyHorizontal) Theme.spacing.medium else 0.dp
)

@Composable
fun Modifier.smallSpacing(applyVertical: Boolean = true, applyHorizontal : Boolean = true): Modifier =
padding(
top = if (applyVertical) Theme.spacing.small else 0.dp,
bottom = if (applyVertical) Theme.spacing.small else 0.dp,
start = if (applyHorizontal) Theme.spacing.small else 0.dp,
end = if (applyHorizontal) Theme.spacing.small else 0.dp
)

These extension functions provide a convenient way to apply the predefined spacing values. By default, they apply padding vertically and horizontally, but you can customize this behavior using the applyVertical and applyHorizontal parameters. I have found it rare to need padding on only one side, but that is where it is still applicable to use padding(top=…).

Using the Extension Functions

Here’s how to use these extension functions in composables:

@Composable
fun MyScreen() {
Column(
modifier = Modifier
.mediumPadding()
.fillMaxSize()
) {
Text("Hello World!", modifier = Modifier.smallPadding())
Spacer(modifier = Modifier.height(MaterialTheme.spacing.large))
Button(
onClick = { /*TODO*/ },
modifier = Modifier.mediumPadding(applyHorizontal = false)
) {
Text("Click Me")
}
}
}

Modifier.mediumPadding() and Modifier.smallPadding() apply the predefined spacing values in this example. This approach reduces repetitive code and clarifies the intent, promoting consistency throughout the application.

Team Awareness of the API

While adding these extension functions simplifies the application of spacing values, it does require that your development team is aware of and uses the custom API. It is important to document these extensions and ensure they are included in your team’s coding guidelines. Providing examples and encouraging code reviews can help reinforce the usage of these custom modifier extensions.

Use Cases for Composition Local

Composition Local can be utilized for various UI configurations to maintain consistency and flexibility across your Jetpack Compose applications. Here are some good use cases where Composition Local can be particularly beneficial:

Spacing:

  • Centralizing spacing values to ensure consistent padding and margins throughout the app.
  • Simplifying the application of spacing with extension functions on Modifier.

Shaping (Corner Radius):

  • Defining default shapes for UI components such as buttons, cards, and containers.
  • Managing corner radii to create a cohesive design language.

Borders:

  • Standardizing border styles, widths, and colors for different UI components.
  • Easily updating border configurations from a single source.

Status Colors:

  • Defining a set of colors for various statuses such as good, warning, and error.
  • Ensuring consistent use of status colors across the application.

Elevation:

  • Standardizing elevation levels for shadows and surface heights.
  • Ensuring a cohesive look and feel with consistent shadow depths.

Animation Duration:

  • Centralizing durations for animations and transitions.
  • Ensuring smooth and consistent animations across the app.

Iconography:

  • Defining sizes and styles for icons.
  • Maintaining a consistent visual language for icons throughout the application.

Status Colors with Composition Local

I typically need a way to convey good, warning, and bad statuses in applications that I write. While the error color is in the Material Theme color scheme, using composition local allows for a more complex use case. The pattern above can be applied to the status colors.

Exporting From Figma

When exporting a Material theme from Figma, it may be beneficial to extract the color hex codes into their own constants. This allows you to use the theme colors beyond the typical @Composable functions. Since MaterialTheme.colorScheme cannot be referenced outside of a @Composable function, extracting these hex values provides greater flexibility. For example, you can use these extracted color values with CompositionLocal to manage theme-specific resources throughout your application, ensuring consistent theming without being constrained by the composable scope.

// Hex color constants
val HEX_MD_THEME_LIGHT_PRIMARY = 0xFF0F6E17
val HEX_MD_THEME_LIGHT_ON_PRIMARY = 0xFFFFFFFF
val HEX_MD_THEME_LIGHT_PRIMARY_CONTAINER = 0xFF9DF890
val HEX_MD_THEME_LIGHT_ON_PRIMARY_CONTAINER = 0xFF002202
val HEX_MD_THEME_LIGHT_SECONDARY = 0xFF53634E
....

val md_theme_light_primary = Color(HEX_MD_THEME_LIGHT_PRIMARY)
val md_theme_light_onPrimary = Color(HEX_MD_THEME_LIGHT_ON_PRIMARY)
val md_theme_light_primaryContainer = Color(HEX_MD_THEME_LIGHT_PRIMARY_CONTAINER)
val md_theme_light_onPrimaryContainer = Color(HEX_MD_THEME_LIGHT_ON_PRIMARY_CONTAINER)
val md_theme_light_secondary = Color(HEX_MD_THEME_LIGHT_SECONDARY)
...

By combining utilizing the constants, the status color can be unified throughout the application.

data class StatusColor(
val success: Color = Color(0xFF00FF00),
val error: Color = Color(HEX_MD_THEME_DARK_ERROR),
val warning: Color = Color(0xFFFFD700),
)

val LocalStatus = compositionLocalOf { StatusColor() }

val Theme.statusColor: StatusColor
@Composable
@ReadOnlyComposable
get() = LocalSpacing.current

Wrap Up

That wraps up some of the use cases and power of Composition Local in Jetpack Compose and demonstrates how it can effectively manage various UI configurations. Composition Local provides a flexible and efficient way to centralize and propagate configuration values throughout your application, from spacing and shaping to status colors and beyond.

By leveraging Composition Local, you can ensure consistency across your UI, reduce boilerplate code, and simplify the management of global settings. This approach enhances the maintainability of your codebase and improves the overall user experience by creating a cohesive and well-structured design.

As you continue to develop your Jetpack Compose applications, consider integrating Composition Local to manage other UI-related configurations such as typography, elevation, animation durations, and iconography. By doing so, you’ll be well-equipped to build flexible, maintainable, and visually consistent applications.

--

--