Writing your own modifier¶
A walk-through of the simplest modifier in the cookbook (pressScale) built up step by step. Once you've internalised the pattern here, the others are variations on a theme.
What we're building¶
A modifier that scales the receiver down on press and springs it back on release. Public API:
fun Modifier.pressScale(
scale: Float = 0.96f,
animationSpec: AnimationSpec<Float> = DefaultPressSpring,
): Modifier
Step 1: pick the right mixins¶
We need to: 1. Observe pointer events (down, up, cancel). 2. Apply a scale transform.
PointerInputModifierNode covers the first. For the transform we have two options: - DrawModifierNode and apply the scale via drawScope.scale { drawContent() }. - LayoutModifierNode and apply the scale via placeable.placeWithLayer { scaleX = … }.
The second is cheaper and uses the canonical layer system. We pick that.
Step 2: write the public function¶
Validate inputs outside the node so callers see errors immediately, not at attach time:
fun Modifier.pressScale(
scale: Float = DEFAULT_PRESSED_SCALE,
animationSpec: AnimationSpec<Float> = DefaultPressSpring,
): Modifier {
require(scale > 0f && scale <= 1f) { "pressScale: `scale` must be in (0f, 1f], was $scale" }
return this then PressScaleElement(scale, animationSpec)
}
The function returns this then Element(…), which is how Compose binds modifiers into a chain.
Step 3: the ModifierNodeElement¶
This is the immutable carrier between composition and the node. data class gives us correct equals / hashCode for free, which Compose uses to decide whether to call update():
private data class PressScaleElement(
val scale: Float,
val animationSpec: AnimationSpec<Float>,
) : ModifierNodeElement<PressScaleNode>() {
override fun create(): PressScaleNode = PressScaleNode(scale, animationSpec)
override fun update(node: PressScaleNode) {
node.update(scale, animationSpec)
}
override fun InspectorInfo.inspectableProperties() {
name = "pressScale"
properties["scale"] = scale
}
}
Three methods. create() is called once when the modifier is first attached. update() is called every time the params change, with the existing node. That's where you mutate, not recreate. inspectableProperties gives the layout inspector something useful to display.
Step 4: the node¶
The node holds state (isPressed, the Animatable) and implements the chosen mixins:
internal class PressScaleNode(
private var pressedScale: Float,
private var animationSpec: AnimationSpec<Float>,
) : Modifier.Node(), PointerInputModifierNode, LayoutModifierNode {
private val scaleValue = Animatable(1f)
private var isPressed = false
private var animationJob: Job? = null
The Animatable lives for the life of the node. Tasks launched via coroutineScope.launch { … } are cancelled automatically in onDetach(), so no manual cleanup is needed.
Step 5: handle update¶
Re-target the in-flight animation rather than throwing it away:
fun update(newScale: Float, newSpec: AnimationSpec<Float>) {
animationSpec = newSpec
if (newScale != pressedScale) {
pressedScale = newScale
if (isPressed) runAnimation(pressedScale)
}
}
If the user is currently pressing, the scale animation re-targets to the new value. If they're not, the next press picks up the new value. Either way: no node recreation, no animation hitch.
Step 6: pointer handling¶
override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) {
if (pass != PointerEventPass.Main) return
val changes = pointerEvent.changes
when (pointerEvent.type) {
PointerEventType.Press -> if (!isPressed && changes.any { it.changedToDownIgnoreConsumed() }) {
isPressed = true
runAnimation(pressedScale)
}
PointerEventType.Release -> if (isPressed && changes.all { it.changedToUpIgnoreConsumed() }) {
isPressed = false
runAnimation(1f)
}
// out-of-bounds drag ⇒ release
}
}
override fun onCancelPointerInput() {
if (isPressed) {
isPressed = false
runAnimation(1f)
}
}
We observe events; we never consume them. Downstream clickable and other gesture detectors keep working. onCancelPointerInput is the safety net: when a parent steals the gesture, we recover to flat.
Step 7: apply the scale at placement¶
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
placeable.placeWithLayer(x = 0, y = 0) {
val s = scaleValue.value
scaleX = s
scaleY = s
transformOrigin = TransformOrigin.Center
}
}
}
private fun runAnimation(target: Float) {
animationJob?.cancel()
animationJob = coroutineScope.launch {
scaleValue.animateTo(target, animationSpec) {
invalidateMeasurement()
}
}
}
Two things to note: - placeWithLayer configures a GraphicsLayer for the child. The scale, transform origin, and any other layer properties are set inside the lambda. - invalidateMeasurement() from inside the animateTo callback re-runs the placement on each animation frame. That's how the scale becomes visible. We don't trigger recomposition.
What you've built¶
That's the whole pattern. Every modifier in the cookbook is the same shape: - A factory fun Modifier.x(...) that validates and emits an element. - A ModifierNodeElement data class with create / update / inspectableProperties. - A node that mixes in the right interfaces, owns state, and drives invalidation through the appropriate invalidate* call.
When you find yourself reaching for Modifier.composed: stop, ask "where does this state want to live?", and write a node instead.