Compose Modifier.Node and where to find it

Merab Tato Kutalia
ProAndroidDev
Published in
4 min readSep 20, 2023

--

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.

https://github.com/android/compose-samples/blob/main/Jetcaster/app/src/main/java/com/example/jetcaster/util/GradientScrim.kt

This modifier is responsible for drawing a vertical gradient scrim in the foreground.

  1. 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)

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.

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 via update callback from ModifierNodeElement

Summary

Modifier.Node provides a new, performant way of creating modifiers. Key benefits are:

  • Less allocations
  • Less composition
  • Smaller tree
  • Better performance
  • Backward compatible

--

--