How I Learned to Stop Worrying About Custom Components and Love Compose
A guiding principle for Jetpack Compose is to provide small, focused pieces of functionality that can be assembled (or composed) together, rather than...
Introduction
Jetpack Compose is Android's new declarative UI toolkit and something I'm very excited about. Compose requires a completely different perspective to the imperative 'XML' based one we're accustomed to as Android developers in favor of a 'Kotlin' based declarative based one that's more in line with what the industry, in general, has begun to favor over the last several years.
A guiding principle for Jetpack Compose is to provide small, focused pieces of functionality that can be assembled (or composed) together, rather than a few monolithic components. This approach has a number of advantages.
Jetpack Compose itself is not a single monolithic project; it is created from a number of modules that are assembled together to form a complete stack. Understanding the different modules that makeup Jetpack Compose enables you to:
- Use the appropriate level of abstraction to build your app or library
- Understand when you can 'drop down' to a lower level for more control or customization
- Minimize your dependencies
What is a 'Composable'?
The Compose Compiler and Runtime are the foundation of Compose. The Compose Compiler is a Kotlin Compiler Plugin that's used to transform the output of a 'Composable' function into UI. The Compose Runtime is responsible for providing building blocks to help leverage the plugin and handle state management.
'Composable' functions are the fundamental building blocks of an application built with Compose.
'Composable' can be applied to a function or lambda to indicate that the function/lambda can be used as part of a composition to describe a transformation from application data into a tree or hierarchy.
In other words:
"The output of a Composable function call hierarchy is a tree of objects which have properties. What you do with those objects is up to you and your imagination" - Jake Wharton
The Layers of Compose
Each layer is built upon the lower levels, combining functionality to create higher-level components. Each layer builds on public APIs of the lower layers to verify the module boundaries and enable you to replace any layer should you need to
The major layers of Compose are:
Runtime
This module provides the fundamentals of the Compose runtime such as remember', 'mutableStateOf, the '@Composable' annotation and 'SideEffect', etc. You might consider building directly upon this layer if you only need Compose’s tree management abilities, not its UI.
UI
The UI layer is made up of multiple modules ('ui-text', 'ui-graphics', 'ui-tooling', etc.). These modules implement the fundamentals of the UI toolkit, such as 'LayoutNode', 'Modifier', input handlers, custom layouts, and drawing. You might consider building upon this layer if you only need fundamental concepts of a UI toolkit
Foundation
This module provides design system-agnostic building blocks for Compose UI, like Row and Column, LazyColumn, recognition of particular gestures, etc. You might consider building upon the foundation layer to create your own design system.
Material
This module provides an implementation of the Material Design system for Compose UI, providing a theming system, styled components, ripple indications, icons. Build upon this layer when using Material Design in your app.
Decoupling Button
Assembling higher level components from smaller building blocks makes it far easier to customize components should you need to. For example, consider 'Button' from the Material layer. 'Button' is simply a 'Surface' and 'Row'. This is similar to 'FrameLayout' and a horizontal 'LinearLayout'. Some attributes like 'alpha', 'minWidth', 'minHeight' and typography are applied to match the Material Design spec. But Composable functions are designed to be simple, stateless widgets.
Surface(
onClick: ...
) {
...
Row(
Modifier
.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
Separation of Colors
Composables also have the benefit of separating their business and UI logic by design, so things like colors can be handled separately using an interface that represents colors and states a button requires for theming.
@Stable
interface ButtonColors {
@Composable
fun backgroundColor(enabled: Boolean): State<Color>
@Composable
fun contentColor(enabled: Boolean): State<Color>
}
@Immutable
private class DefaultButtonColors(
private val backgroundColor: Color,
private val contentColor: Color,
private val disabledBackgroundColor: Color,
private val disabledContentColor: Color
) : ButtonColors {
@Composable
override fun backgroundColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(if (enabled) backgroundColor else disabledBackgroundColor)
}
@Composable
override fun contentColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(if (enabled) contentColor else disabledContentColor)
}
override fun equals(object: Any?) ...
override fun hashCode() ...
'Stable' is used to communicate some guarantees to the compose compiler about how a certain type or function will behave. When applied to a class or an interface, 'Stable' indicates that the following must be true:
- The result of equals will always return the same result for the same two instances.
- When a public property of the type changes, composition will be notified.
- All public property types are stable.
'Immutable' can be used to mark class as producing immutable instances. The immutability of the class is not validated and is a promise by the type that all publicly accessible properties and fields will not change after the instance is constructed. This is a stronger promise than 'val' as it promises that the value will never change not only that values cannot be changed through a setter.
In other words:
"'Immutable' implies 'Stable'. 'Stable' means that for object instances 'a' and 'b', the result of 'a.equals(b)' will always return the same result regardless of any changes made to either 'a' or 'b'. In practice this means any change to 'a' is reflected in 'b' and vice versa; they share some sort of internal structure. 'Immutable' implies 'Stable' because immutable can't change at all, trivially satisfying this contract." - Adam Powell
Composing Our Own Button
If we're interested in customizing a 'Button' outside of Material Design though, we then we can simply ‘drop down’ a level and fork a component. When dropping down to a lower layer to customize a component, ensure that you do not degrade any functionality by, for example, neglecting accessibility support. Use the component you are forking as a guide.
Let's walkthrough creating a simple 'Button' we can reuse throughout our project that's free of Material Design. We can start by using the Material layer 'Button' as a base for our own implementation:
@Composable
fun JbsButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
contentPadding: PaddingValues = JbsButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
) {
Surface(
modifier = modifier,
elevation = 0.dp,
onClick = onClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple()
) {
ProvideTextStyle(value = JbsTheme.typography.button) {
Row(
Modifier
.defaultMinSize(
minWidth = JbsButtonDefaults.MinWidth,
minHeight = JbsButtonDefaults.MinHeight
)
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}
Next we can define an 'interface' to represent the colors and states we expect our 'Button' to be in:
@Stable
interface JbsButtonColors {
@Composable
fun backgroundColor(enabled: Boolean): State<Color>
@Composable
fun contentColor(enabled: Boolean): State<Color>
@Composable
fun rippleColor(): State<Color>
}
We can go back and update our 'Button' implementation to consume our new interface.
@Composable
fun JbsButton(
...
colors: JbsButtonColors = PrimaryButtonColors(),
...
) {
val backgroundColor by colors.backgroundColor(enabled)
val contentColor by colors.contentColor(enabled)
val rippleColor by colors.rippleColor()
Surface(
...
color = backgroundColor,
contentColor = contentColor,
...
indication = rememberRipple(color = rippleColor)
) {
...
}
}
@Immutable
class PrimaryButtonColors : JbsButtonColors {
@Composable
override fun backgroundColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(Color(color = 0xFFFD6F96))
}
@Composable
override fun contentColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(Color(color = 0xFFFFEBA1))
}
@Composable
override fun rippleColor(): State<Color> {
return rememberUpdatedState(Color(color = 0xFF95DAC1))
}
}
@Immutable
class SecondaryButtonColors : JbsButtonColors {
@Composable
override fun backgroundColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(Color(color = 0xFFB8DFD8))
}
@Composable
override fun contentColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(Color(color = 0xFFE8F6EF))
}
@Composable
override fun rippleColor(): State<Color> {
return rememberUpdatedState(Color(color = 0xFFFFB319))
}
}
@Immutable
class TertiaryButtonColors : JbsButtonColors {
@Composable
override fun backgroundColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(Color(color = 0xFFFEC260))
}
@Composable
override fun contentColor(enabled: Boolean): State<Color> {
return rememberUpdatedState(Color(color = 0xFFA12568))
}
@Composable
override fun rippleColor(): State<Color> {
return rememberUpdatedState(Color(color = 0xFF3B185F))
}
}
@Composable
fun CounterButton(
count: Int,
colors: JbsButtonColors,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
JbsButton(
modifier = modifier,
onClick = onClick,
colors = colors
) {
Text(text = "Count: $count")
}
}
@Preview
@Composable
fun CounterButtonPreview() {
var count by remember { mutableStateOf(0) }
val increment = { count += 1 }
Row(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
CounterButton(
count = count,
onClick = increment,
colors = PrimaryButtonColors()
)
CounterButton(
count = count,
onClick = increment,
colors = SecondaryButtonColors()
)
CounterButton(
count = count,
onClick = increment,
colors = TertiaryButtonColors()
)
}
}
Conclusion
I love Jetpack Compose. Sometimes I "joke" I would sell it door-to-door if I could. Compose’s philosophy of building layered, reusable components mean that you should not always reach for the lower-level building blocks. Many higher-level components not only offer more functionality but often implement best practices such as supporting accessibility.
As a rule, prefer building on the highest-level component which offers the functionality you need in order to benefit from the best practices they include. If you like working on this kind of stuff we're hiring!
Further Reading
The JBS Quick Launch Lab
Free Qualified Assessment
Quantify what it will take to implement your next big idea!
Our assessment session will deliver tangible timelines, costs, high-level requirements, and recommend architectures that will work best. Let JBS prove to you and your team why over 24 years of experience matters.