Modifiers in Jetpack Compose are a powerful tool for customising and enhancing UI components. They allow developers to modify the appearance, behaviour, and layout of composable functions without changing their core implementation.
- Chainable: Modifiers can be chained together, allowing for multiple modifications to be applied sequentially.
- Reusable: Custom modifiers can be created and reused across different components, promoting code reusability.
- Extensible: Developers can create their own custom modifiers to add specific functionality.
The Reusable and Extensible aspects is what make modifiers super powerful. Hence in this blog we’ll be looking at how to create custom modifiers.
- composed { }
- @composable modifier factory
- Modifier.Node API
The composed() function is a convenient way to create custom modifiers in Jetpack Compose. It allows you to define a modifier that can contain composable content.
Here’s a basic structure of using composed():
fun Modifier.customModifier(
// parameters
) = composed {
// Your custom modifier logic here
// Can include other composables
this.then(
// Additional modifiers
)
}
Key points about using composed():
- Composable context: Inside composed{}, you have access to the composable context, allowing you to use other @Composable functions.
- State observation: You can observe state and trigger recomposition when needed.
- Chaining: Use this.then() to chain additional modifiers.
Example of a custom modifier using composed():
This example creates a shimmer effect modifier that can be applied to any composable to add a shimmering animation.
Another approach to creating custom modifiers in Jetpack Compose is using @Composable modifier factories. This method allows you to create modifiers that can use other @Composable functions and observe state changes.
Here’s the basic structure of a @Composable modifier factory:
@Composable
fun Modifier.customModifier(
// parameters
): Modifier {
// Your custom modifier logic here
return this.then(
// Additional modifiers
)
}
Key points about using @Composable modifier factories:
- Composable context: The function is marked with @Composable, giving you access to other composable functions and state.
- Return type: The function explicitly returns a Modifier.
- Flexibility: You can use remember, derivedStateOf, and other composable functions within the modifier.
Example of a custom modifier using @Composable modifier factory:
This example creates a pulsating scale effect modifier that can be applied to any composable to add a pulsating animation. The scale and duration of the pulse can be customised through parameters.
This is an excellent article which explains the key differences b/w the 2 approaches. Here is the summary:
1. Extractability: CMF is limited to use within the Composition scope, while composed() can be extracted and used more flexibly.
2. CompositionLocal resolution: CMF resolves CompositionLocal values at the call site, while composed() resolves them at the usage site.
3. State resolution: CMF resolves state only once at the call site, while composed() resolves state at the usage site for each Layout.
4. Performance: CMF performs better than composed() due to avoiding the expensive materialize() call.
As we can see above, creating custom modifiers using composed { }
makes more sense than using the CMF approach. Using CMF is ideal when you need inline modifiers or extract a modifier for using it in only one component. On the other hand, composed is useful when designing generic modifiers.
But here’s the catch, the composed way has few performance issues and the new recommended way of creating custom modifiers is to use the Modifier.Node API.
Compose 1.3 introduced the Modifier.Node API where the team has migrated all the pre-defined modifiers to this new API. I would highly recommend to watch this youtube video of Android Dev Summit which explains why this change has been done.
So if you want the best of both worlds — performance, extractability, skippability, reusable modifiers, use Modifier.Node API
There are three parts to implementing a custom modifier using Modifier.Node:
- A
Modifier.Node
implementation that holds the logic and state of your modifier. - A
ModifierNodeElement
that creates and updates modifier node instances. - An optional modifier factory as detailed above.
ModifierNodeElement
classes are stateless and new instances are allocated each recomposition, whereas Modifier.Node
classes can be stateful and will survive across multiple recompositions, and can even be reused.
Here is the very basic example of drawing a circle of specific color as shared in the official documentation.
Let’s breakdown to our process
Modifier.Node:
The first step is to create a class which implements the Modifier.Node
along with DrawModifierNode
. There are multiple factory nodes which compose provides out of the box. Here we want to draw something hence we are using the DrawModifierNode
. If we wanted to do something with user inputs or gestures we might want to use PointerInputModifierNode
ModifierNodeElement:
A ModifierNodeElement
is an immutable class that holds the data to create or update your custom modifier:
// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)override fun update(node: CircleNode) {
node.color = color
}
}
ModifierNodeElement
implementations need to override the following methods:
create
: This is the function that instantiates your modifier node. This gets called to create the node when your modifier is first applied. Usually, this amounts to constructing the node and configuring it with the parameters that were passed in to the modifier factory.update
: This function is called whenever this modifier is provided in the same spot this node already exists, but a property has changed. This is determined by theequals
method of the class. The modifier node that was previously created is sent as a parameter to theupdate
call. At this point, you should update the nodes’ properties to correspond with the updated parameters. The ability for nodes to be reused this way is key to the performance gains thatModifier.Node
brings; therefore, you must update the existing node rather than creating a new one in theupdate
method. In our circle example, the color of the node is updated.
Additionally, ModifierNodeElement
implementations also need to implement equals
and hashCode
. update
will only get called if an equals comparison with the previous element returns false.
Modifier Factory:
This is the public API surface of your modifier. Most implementations simply create the modifier element and add it to the modifier chain:
// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)
In this blog post, we’ve explored the power and flexibility of custom modifiers in Jetpack Compose. We’ve covered three main approaches to creating custom modifiers:
- Using the Composable Modifier Function (CMF)
- Using the composed { } function
- Using the new Modifier.Node API (recommended)
We’ve learned that while CMF and composed { } methods have their uses, the Modifier.Node API is now the recommended approach for creating custom modifiers. This new API offers better performance, reusability, and extensibility.
Key takeaways:
- The Modifier.Node API consists of three main parts: a Modifier.Node implementation, a ModifierNodeElement, and an optional modifier factory.
- There are various types of modifier nodes available for different purposes, such as DrawModifierNode, LayoutModifierNode, and PointerInputModifierNode.
- The ModifierNodeElement is responsible for creating and updating modifier node instances, which can be stateful and survive across recompositions.
- The modifier factory provides a clean, public API for using your custom modifier.
By mastering custom modifiers, especially using the Modifier.Node API, you can create more efficient, reusable, and powerful UI components in your Jetpack Compose applications.