Photo by Hal Gatewood on Unsplash
In the recent release of Jetpack Compose 1.5 we got Modifier.Node as stable. There was some hype around this change, so let’s see what this new API can offer and how we can implement it. This API is not well-documented yet, so it is kind of wild west for now.
With the help of the new Modifier.Node we can create lightweight modifiers that are not composable and thus more performant. Modifiers like padding and background will be treated as a new type of lightweight immutable Modifier.Element
that knows how to maintain an instance of a corresponding Modifier.Node
class. Even clickable
modifier has migrated to this new API and the Compose team claims almost 80% performance improvement.
How it works?
Because new modifier elements are immutable, it is significantly easier and cheaper to compare to the previous state. It is a common scenario when almost nothing is changed in modifiers between the recomposition, so Compose can skip and even doesn’t need to apply the modifier at all. When the modifier is changed, Compose then diffs the previous list of modifiers with the new ones, and because all of them are comparable, Compose only applies the modified(pun intended) modifier.
In addition to that Modifier.Node#coroutineScope
allows Modifier.Nodes to launch coroutines and read CompositionLocals by implementing the CompositionLocalConsumerModifierNode interface.
For in-depth stories, I highly recommend listening to the Compose Performance episode from the Android Developers Backstage podcast
What about Modifier.composed {}
Modifier.composed{}
is not going anywhere but is already removed from the Compose guidelines documentation. We still need it for some cases where we access composables from modifiers but it will not be the only option to create new modifiers from now on. Many of the Compose modifiers have been migrated to the new Modifier.Node already. The biggest problems with the composed
modifier are:
- has a Modifier return type and compostables with return type are not skippable during the recomposition.
composed
is not a composable so Compose compiler cannot memoize lambda and cannot compare to previous value even if there is no change.
Even if we don’t use composed{}
modifier excessively, basic composable functions use dozens of modifiers that were made from composed
internally.
There is an awesome video by Leland Richardson that goes through the details of this change.
From that video, we understand that if we aggregate the contents of Modifier.clickable{}
then we can see the result (before introducing Modifier.Node — Compose 1.3):
- 13 Modifier.composed calls
- 34 remember calls
- 11 Side Effects
- 16 Leaf Modifier.Elements
Mind-blowing That’s why we see huge performance improvement in the Compose internally. Upgrade to Compose 1.5 at least 😉 It is backward compatible, with no change on the consumer side.
If you are a library developer or just contributing a modifier to the project, think twice about which API you need.
Here is the list of already existing Modifier Nodes: https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier.Node
Implementation
Implementation is 3 step process, we can take a look at the modifiers in the Compose samples.
This modifier is responsible for drawing a vertical gradient scrim in the foreground.
- First we create a modifier extension function
fun Modifier.verticalGradientScrim
and chain to Modifier node element.
fun Modifier.verticalGradientScrim( color: Color, @FloatRange(from = 0.0, to = 1.0) startYPercentage: Float = 0f, @FloatRange(from = 0.0, to = 1.0) endYPercentage: Float = 1f, decay: Float = 1.0f, numStops: Int = 16 ) = this then VerticalGradientElement(color, startYPercentage, endYPercentage, decay, numStops)
Job Offers
2) Implement ModifierNodeElement with 2 core functions — create() and update(). Add inspector info for LayoutInspector.
private data class VerticalGradientElement( var color: Color, var startYPercentage: Float = 0f, var endYPercentage: Float = 1f, var decay: Float = 1.0f, var numStops: Int = 16 ) : ModifierNodeElement<VerticalGradientModifier>() {}
3) Implement Modifier.Node and already existing interface DrawModifierNode that draws into the space of the layout. This is the androidx.compose.ui.Modifier.Node equivalent of androidx.compose.ui.draw.DrawModifier
private class VerticalGradientModifier( var onDraw: DrawScope.() -> Unit ) : Modifier.Node(), DrawModifierNode { override fun ContentDrawScope.draw() { onDraw() drawContent() } }
That’s it. Keep in mind that leaving `VerticalGradientModifier` private means that the node will not be delegated — https://developer.android.com/reference/kotlin/androidx/compose/ui/node/DelegatingNode
Migration
This is the part that is not presented/documented well. Every use case is different but we can still get the idea of how to construct new modifiers.
Migrating existing Modifier.Node
can be a bit challenging in the beginning because of the extra steps and rearchitecting modifier code. Here is a very good example of how the Compose team migrated Hoverable
modifier to Modifier.Node.
Migrate Modifier.hoverable to Modifier.Node · androidx/androidx@50d1b9d
A few important notes. Previously composed
had remember
, mutableStateOf
, and various side effects in the method body. After migration we see them disappear but the parts of the code moved to the different callbacks.
- CoroutineScope is accessible from
Modifier.Node
- Consider passing additional values from outside (like IME state), instead of handling them in the modifier because new Modifier.Node can’t access composable, so we need to find workarounds.
- All the
remember
-ed values can be stored as class members and update them viaupdate
callback fromModifierNodeElement
Summary
Modifier.Node provides a new, performant way of creating modifiers. Key benefits are:
- Less allocations
- Less composition
- Smaller tree
- Better performance
- Backward compatible
This article was previously published on proandroiddev.com