Custom modifiers in Jetpack Compose
Custom modifiers in Jetpack Compose 관련
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.
Key aspects of modifiers
- 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.
Three Ways
composed { }
@Composable
modifier factory- Modifier.Node API
Using composed()
to Create Custom Modifiers
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()
:
fun Modifier.shimmerEffect() = composed {
val size = remember { mutableStateOf(IntSize(0, 0)) }
val transition = rememberInfiniteTransition()
val startOffsetX = transition.animateFloat(
initialValue = -2 * size.value.width.toFloat(),
targetValue = 2 * size.value.width.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(1000)
)
)
background(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFFB8B5B5),
Color(0xFF8F8B8B),
Color(0xFFB8B5B5),
),
start = Offset(startOffsetX.value, 0f),
end = Offset(startOffsetX.value + size.value.width.toFloat(), size.value.height.toFloat())
)
).onGloballyPositioned {
size.value = it.size
}
}
This example creates a shimmer effect modifier that can be applied to any composable to add a shimmering animation.
Using @Composable
Modifier Factory to Create Custom Modifiers
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:
@Composable
fun Modifier.pulsatingScale(
pulseFraction: Float = 1.2f,
duration: Int = 1000
): Modifier {
val infiniteTransition = rememberInfiniteTransition()
val scale = infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = pulseFraction,
animationSpec = infiniteRepeatable(
animation = tween(duration),
repeatMode = RepeatMode.Reverse
)
)
return scale(scale.value)
}
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.
Info
This is an excellent article which explains the key differences b/w the 2 approaches. Here is the summary:
- Extractability: CMF is limited to use within the Composition scope, while composed() can be extracted and used more flexibly.
- CompositionLocal resolution: CMF resolves CompositionLocal values at the call site, while composed() resolves them at the usage site.
- State resolution: CMF resolves state only once at the call site, while composed() resolves state at the usage site for each Layout.
- Performance: CMF performs better than composed() due to avoiding the expensive materialize() call.
The Recommended Way: New Modifier.Node API
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.
Info
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.
// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)
// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
override fun create() = CircleNode(color)
override fun update(node: CircleNode) {
node.color = color
}
}
// Modifier.Node
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() {
drawCircle(color)
}
}
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:
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)