Von Modifier.composed zu Modifier.Node migrieren

Modifier.composed wurde in Compose 1.0 eingeführt, damit Sie über Modifier auf Kompositionselemente zugreifen können. Ein wichtiger Anwendungsfall ist beispielsweise das Erstellen eines zustandsorientierten Modifikators, der sich den lokalen Zustand merkt und ihn mit anderen Modifikatoren in der Modifier.composed-Fabrikmethode teilt:

// ❌ BAD: Using Modifier.composed is no longer recommended
fun Modifier.pressScale(
    pressedScale: Float = 0.95f,
    onClick: () -> Unit
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "pressScale"
        properties["pressedScale"] = pressedScale
    }
) {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()
    val scale by animateFloatAsState(
        targetValue = if (isPressed) pressedScale else 1f,
        animationSpec = spring(),
        label = "pressScale"
    )

    this
        .graphicsLayer {
            scaleX = scale
            scaleY = scale
        }
        .clickable(
            interactionSource = interactionSource,
            indication = null,
            onClick = onClick
        )
}

Verwenden Sie Modifier.Node anstelle von Modifier.composed, da Modifier.Node die Verwaltung des Status in Modifikatoren verbessert. Ein Modifier.Node ist ein langlebiges, zustandsorientiertes Objekt, das einmal pro Modifier.Element erstellt wird, das auf ein LayoutNode angewendet wird. Es übersteht die Neukomposition, anstatt bei jedem Durchgang durch die Komposition neu materialisiert zu werden. Weitere Informationen dazu, warum und wie wir Modifier.Node entwickelt haben, finden Sie unter Compose-Modifikatoren im Detail.

In diesem Dokument wird beschrieben, wie Sie von Modifier.composed zu Modifier.Node migrieren. Weitere Informationen zur allgemeinen Verwendung dieser API finden Sie unter Benutzerdefiniertes Modifier-Verhalten mit Modifier.Node implementieren.

Leistungsvorteile von Modifier.Node

Die Verwendung von Modifier.composed führt zu mehreren grundlegenden Leistungsengpässen:

  • Overhead für die Statusverwaltung:Die Verwaltung des Status in diesem Bereich erfordert remember-Aufrufe und Snapshot-Statusobjekte, wodurch die Slot-Tabelle mit unnötigen Kompositionsgruppen aufgebläht und der Arbeitsspeicherbedarf erhöht wird.
  • Teurer Lebenszyklus-Zugriff:Für den Zugriff auf den Lebenszyklus des Modifikators sind Effekte wie DisposableEffect erforderlich, was den Aufwand für die einfacheren Anwendungsfälle schnell erhöht.
  • Fehlende Überspringbarkeit:Da die an composed übergebene Lambda-Funktion Modifier zurückgibt, kann der Compose-Compiler sie nicht als überspringbar markieren. Sie muss also bei jeder Neukomposition des Layouts neu ausgeführt werden.
  • Defekte Memoization und Gleichheit:Da die äußere Erweiterungsfunktion selbst keine @Composable ist, kann der Compiler das innere Lambda nicht memoizieren. Das führt dazu, dass bei jedem Aufruf neue Lambdas zugewiesen werden. Durch das Fehlen der Memoization wird die Gleichheit von Modifikatoren (equals) direkt unterbrochen, da ComposedModifier Lambdas nach Referenz vergleicht. Daher wird der Modifier in Compose bei jedem Frame als geändert behandelt, auch wenn die Parameter statisch sind.
  • Keine intelligente Weitergabe von Änderungen:Ohne die Nachverfolgung von zusammensetzbaren Parametern auf oberster Ebene ist es nicht möglich, neue Eingaben mit vorherigen zu vergleichen, um Änderungen intelligent weiterzugeben.

Insgesamt führt die Modifier.composed-API-Form dazu, dass teurer Code geschrieben wird, und verhindert, dass die Compose-Laufzeit zusätzliche Modifikatoroptimierungen anwendet.

Wichtige Migrationsschritte

Das folgende Beispiel zeigt einen typischen benutzerdefinierten Modifikator, der mit Modifier.composed implementiert wurde. Weitere Informationen finden Sie unter Benutzerdefiniertes Modifier-Verhalten mit Modifier.Node implementieren.

fun Modifier.underline(
    color: Color,
    thickness: Dp = 2.dp,
    animationDurationMillis: Int = 300
): Modifier = composed {
    val density = LocalDensity.current
    val strokePx = with(density) { thickness.toPx() }

    // Drives how much of the underline is drawn: 0f -> 1f
    val progress = remember { Animatable(0f) }

    LaunchedEffect(color, thickness) {
        progress.snapTo(0f)
        progress.animateTo(
            targetValue = 1f,
            animationSpec = tween(durationMillis = animationDurationMillis)
        )
    }

    drawBehind {
        val y = size.height - strokePx / 2
        drawLine(
            color = color,
            start = Offset(0f, y),
            end = Offset(size.width * progress.value, y),
            strokeWidth = strokePx
        )
    }
}

  1. So erstellen Sie eine benutzerdefinierte Modifier.Node (oder DelegatingNode):

    private class UnderlineNode(
        private var color: Color,
        private var thickness: Dp,
        private var animationDurationMillis: Int
    ) : Modifier.Node() {
        fun update(color: Color, thickness: Dp, durationMillis: Int) {
        }
    }

  2. Implementieren Sie je nach Bedarf eine oder mehrere der Hilfs-APIs von Modifier.Node, z. B. PointerInputModifierNode, wenn Ihr benutzerdefinierter Modifier Zugriff auf Pointer-Eingabe-APIs benötigt:

    private class UnderlineNode(
        private var color: Color,
        private var thickness: Dp,
        private var animationDurationMillis: Int
    ) : Modifier.Node(), DrawModifierNode {
    
        private val progress = Animatable(0f)
        private var animationJob: Job? = null
    
        override fun onAttach() {
            restartAnimation()
        }
    
        fun update(color: Color, thickness: Dp, durationMillis: Int) {
            val needsRestart = this.color != color || this.thickness != thickness
            this.color = color
            this.thickness = thickness
            this.animationDurationMillis = durationMillis
            if (needsRestart) restartAnimation()
        }
    
        private fun restartAnimation() {
            animationJob?.cancel()
            animationJob = coroutineScope.launch {
                progress.snapTo(0f)
                progress.animateTo(1f, tween(animationDurationMillis))
            }
        }
    
        override fun ContentDrawScope.draw() {
            val strokePx = thickness.toPx()
            val y = size.height - strokePx / 2
            drawLine(
                color = color,
                start = Offset(0f, y),
                end = Offset(size.width * progress.value, y),
                strokeWidth = strokePx
            )
            drawContent()
        }
    }

  3. Erstellen Sie eine ModifierNodeElement, mit der Ihr benutzerdefinierter Knoten erstellt und aktualisiert wird:

    private class UnderlineElement(
        private val color: Color,
        private val thickness: Dp,
        private val animationDurationMillis: Int
    ) : ModifierNodeElement<UnderlineNode>() {
    
        override fun create() = UnderlineNode(color, thickness, animationDurationMillis)
    
        override fun update(node: UnderlineNode) {
            node.update(color, thickness, animationDurationMillis)
        }
    
        override fun InspectorInfo.inspectableProperties() {
            name = "underline"
            properties["color"] = color
            properties["thickness"] = thickness
            properties["animationDurationMillis"] = animationDurationMillis
        }
    
        override fun hashCode(): Int {
            var result = color.hashCode()
            result = 31 * result + thickness.hashCode()
            result = 31 * result + animationDurationMillis.hashCode()
            return result
        }
    
        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            val otherElement = other as? UnderlineElement ?: return false
            return color == otherElement.color &&
                thickness == otherElement.thickness &&
                animationDurationMillis == otherElement.animationDurationMillis
        }
    }

  4. Aktualisieren Sie die Modifikator-Factory, damit sie auf ModifierNodeElement verweist:

    fun Modifier.underline(
        color: Color,
        thickness: Dp = 2.dp,
        animationDurationMillis: Int = 300
    ): Modifier = this then UnderlineElement(color, thickness, animationDurationMillis)

Häufige Migrationsrezepte

In den folgenden Rezepten wird gezeigt, wie Sie gängige Muster von Modifier.composed zu Modifier.Node- oder @Composable-Modifikator-Factories migrieren.

Auf eine CompositionLocal zugreifen

Muster:Ein einzelnes CompositionLocal wie LocalDensity, Theme oder LocalView lesen.

Migrationspfad:Markieren Sie den Modifikator mit @Composable. Es gibt einen semantischen Unterschied zwischen der Verwendung eines composed-Modifiers und einer @Composable-Modifier-Factory für den Zugriff auf ein CompositionLocal. Bei einer @Composable-Factory werden CompositionLocal-Werte am Aufrufort der Modifier-Factory aufgelöst. Wenn das nicht beabsichtigt ist, verwenden Sie eine benutzerdefinierte Modifier.Node-Implementierung, die CompositionLocals mit CompositionLocalConsumerModifierNode liest.

Weitere Informationen finden Sie unter Benutzerdefinierten Modifier mit einer zusammensetzbaren Modifier-Factory erstellen.

// ❌ BAD: Using Modifier.composed to read a single CompositionLocal
fun Modifier.themedContainerBorder(): Modifier =
    composed {
        Modifier.border(
            BorderStroke(
                width = 2.dp,
                color = LocalColorScheme.current.primaryColor,
            )
        )
            .clipToBounds()
    }

// ✅ GOOD: If the modifier is @Composable, it should be able to access the locals.
@Composable
fun Modifier.themedContainerBorder() =
    this then Modifier.border(
        BorderStroke(
            width = 2.dp,
            color = MyTheme.mainColor,
        )
    )
        .clipToBounds()

Muster:Lesen eines CompositionLocal, das auf einen nachfolgenden Modifikator angewendet werden kann.

Migrationspfad:Erstellen Sie eine benutzerdefinierte Modifier.Node, die CompositionLocalConsumerModifierNode implementiert und alle Funktionen der Modifizierer kombiniert.

// ❌ BAD: Using Modifier.composed to read a CompositionLocal then using it in another modifier.
fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = composed {
    // Reading LocalThemePadding.current.small (CompositionLocal)
    val extraPadding = LocalThemePadding.current.small
    Modifier.padding(basePadding + extraPadding)
}

// ✅ GOOD: A custom Modifier that combines the capabilities of both (layout and composition local reader) modifiers.
fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier =
    this.then(AdaptivePaddingElement(basePadding))

private data class AdaptivePaddingElement(
    val basePadding: Dp,
) : ModifierNodeElement<AdaptivePaddingNode>() {
    override fun create() = AdaptivePaddingNode(basePadding)

    override fun update(node: AdaptivePaddingNode) {
        node.basePadding = basePadding
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "adaptiveAccessibilityPadding"
        properties["basePadding"] = basePadding
    }
}

private class AdaptivePaddingNode(
    var basePadding: Dp,
) : Modifier.Node(), LayoutModifierNode, CompositionLocalConsumerModifierNode {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val extraPadding = currentValueOf(LocalThemePadding).small
        val total = (basePadding + extraPadding).roundToPx()

        val horizontal = total * 2
        val vertical = total * 2

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)

        return layout(width, height) {
            placeable.place(total, total)
        }
    }
}

Auf eine nicht layoutbezogene komponierbare Funktion zugreifen

Muster:Der Modifikator muss auf eine Funktion zugreifen, die mit @Composable annotiert ist und ein Objekt zurückgibt (z. B. colorResource oder ScrollableDefaults.flingBehavior).

Migrationspfad:Kommentieren Sie den Modifikator mit @Composable.

// ❌ BAD: Using Modifier.composed to access a composable function such as colorResource
fun Modifier.niceBackground() = composed {
    // Reading composable function colorResource
    val gradientColor1 = colorResource(R.color.my_special_color)
    background(color = gradientColor1, shape = CircleShape)
}

// ✅ GOOD: A modifier can be annotation with @Composable to reference composable functions.
@Composable // Modifier can be Composable itself.
private fun Modifier.niceBackground(): Modifier {
    val gradientColor1 = colorResource(R.color.my_special_color)
    return this.background(color = gradientColor1, shape = CircleShape)
}

Auf einen Koroutinenbereich zugreifen

Muster:Modifier.composed wird verwendet, um rememberCoroutineScope auszuführen und auf ein coroutineScope-Objekt zuzugreifen, um Coroutinen zu starten.

Migrationspfad:Verwenden Sie eine benutzerdefinierte Modifier.Node mit einer coroutineScope-Eigenschaft, die an den Lebenszyklus des Modifikators gebunden ist (z. B. rememberCoroutineScope in Modifier.composed):

// ❌ BAD: Using Modifier.composed to get access to a coroutine scope.
fun Modifier.onClickAsyncComposed(onClick: suspend () -> Unit): Modifier =
    composed {
        val scope = rememberCoroutineScope()
        Modifier.pointerInput(onClick) {
            detectTapGestures {
                // Needs a coroutine scope to launch suspend lambda.
                scope.launch {
                    onClick()
                }
            }
        }
    }

// ✅ GOOD: A custom Modifier.Node has a scoped (modifier lifecycle) coroutineScope that can be used to launch async work.
fun Modifier.onClickAsync(onClick: suspend () -> Unit): Modifier =
    this.then(OnClickAsyncElement(onClick))

private data class OnClickAsyncElement(val onClick: suspend () -> Unit) :
    ModifierNodeElement<OnClickAsyncNode>() {
    override fun create(): OnClickAsyncNode = OnClickAsyncNode(onClick)

    override fun update(node: OnClickAsyncNode) {
        node.update(onClick)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "onClickAsync"
        properties["onClick"] = onClick
    }
}

private class OnClickAsyncNode(private var onClick: suspend () -> Unit) :
    DelegatingNode(), PointerInputModifierNode {

    private val pointerInputNode =
        delegate(
            SuspendingPointerInputModifierNode {
                detectTapGestures {
                    // Modifier.Node provides `coroutineScope` directly.
                    coroutineScope.launch { onClick() }
                }
            }
        )

    fun update(onClick: suspend () -> Unit) {
        if (this.onClick != onClick) {
            this.onClick = onClick
            pointerInputNode.resetPointerInputHandler()
        }
    }

    override fun onPointerEvent(
        pointerEvent: PointerEvent,
        pass: PointerEventPass,
        bounds: IntSize,
    ) {
        pointerInputNode.onPointerEvent(pointerEvent, pass, bounds)
    }

    override fun onCancelPointerInput() {
        pointerInputNode.onCancelPointerInput()
    }
}

Mich an ein Bundesland/einen Bundesstaat erinnern

Muster:remember in Modifier.composed verwenden, um den Status über Recompositions hinweg zu speichern.

Migrationspfad:Modifier.Node wurde so entwickelt, dass der Status auf dieselbe Weise beibehalten wird. Der Status kann in einer Instanz gespeichert werden, genau wie jede andere Klassen-Property mit einem klareren Lebenszyklus:

// ❌ BAD: Using Modifier.composed to make the modifier stateful.
fun Modifier.tapCountHighlightComposed(colors: List<Color>): Modifier = composed {
    // 1. Must use `remember` so `tapCount` isn't reset to 0 on every recomposition
    var tapCount by remember { mutableIntStateOf(0) }

    Modifier
        .pointerInput(colors) { detectTapGestures { tapCount++ } }
        .drawBehind { drawRect(colors[tapCount % colors.size]) }
}

// ✅ GOOD: Modifier.Node is the recommended way of creating stateful modifiers.
fun Modifier.tapCountHighlight(colors: List<Color>): Modifier =
    this then TapCountHighlightElement(colors)

private data class TapCountHighlightElement(
    val colors: List<Color>,
) : ModifierNodeElement<TapCountHighlightNode>() {
    override fun create() = TapCountHighlightNode(colors)

    override fun update(node: TapCountHighlightNode) {
        node.updateColors(colors)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "tapCountHighlight"
        properties["colors"] = colors
    }
}

private class TapCountHighlightNode(
    private var colors: List<Color>,
) : DelegatingNode(), DrawModifierNode {
    private var tapCount = 0 // Stateful modifier, this property will survive recompositions since Modifier.Nodes are held in the modifier tree.

    private val pointerInputNode = delegate(
        SuspendingPointerInputModifierNode {
            detectTapGestures {
                tapCount++
                invalidateDraw()
            }
        }
    )

    override fun ContentDrawScope.draw() {
        drawRect(colors[tapCount % colors.size])
        drawContent()
    }

    fun updateColors(colors: List<Color>) {
        this.colors = colors
        invalidateDraw()
    }
}

Effekt verwenden

Muster:Verwenden von Effekten zum Ausführen von Vorgängen, die an den Lebenszyklus der Zusammensetzung gebunden sind, z. B. wenn Modifier.composed in die Zusammensetzung eintritt oder sie verlässt.

Migrationspfad:Modifier.Node hat klare Lifecycle-Callbacks, mit denen dieselben Vorgänge ausgeführt werden können. Beispiel: Ein LaunchedEffect kann in der Regel durch die Verwendung von coroutineScope in der Methode Modifier.Node onAttach ersetzt werden:

// ❌ BAD: Using Modifier.composed to launch/run an effect.
fun Modifier.logImpressionComposed(
    targetId: String,
    onLog: suspend (targetId: String) -> Unit,
): Modifier =
    composed {
        // LaunchedEffect is tied to Composition lifecycle
        LaunchedEffect(targetId) { onLog(targetId) }
        this
    }

// ✅ GOOD: Modifier.Node has lifecycle callbacks (e.g onAttach, onDetach) that can be used to emulate effects behaviors.
fun Modifier.logImpression(targetId: String, onLog: suspend (targetId: String) -> Unit): Modifier =
    this.then(LogImpressionElement(targetId, onLog))

private data class LogImpressionElement(
    val targetId: String,
    val onLog: suspend (targetId: String) -> Unit,
) : ModifierNodeElement<LogImpressionNode>() {
    override fun create(): LogImpressionNode = LogImpressionNode(targetId, onLog)

    override fun update(node: LogImpressionNode) {
        node.update(targetId, onLog)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "logImpression"
        properties["targetId"] = targetId
    }
}

private class LogImpressionNode(
    var targetId: String,
    var onLog: suspend (targetId: String) -> Unit,
) : Modifier.Node() {
    private var job: Job? = null

    override fun onAttach() {
        super.onAttach()
        runEffect() // Uses onAttach to track modifier lifecycle.
    }

    fun update(targetId: String, onLog: suspend (targetId: String) -> Unit) {
        // Re-run the effect if the key (`targetId`) changed
        if (this.targetId != targetId) {
            runEffect()
        }
        this.targetId = targetId
        this.onLog = onLog
    }

    private fun runEffect() {
        job?.cancel()
        job = coroutineScope.launch { onLog(targetId) }
    }
}

Animationsstatus beibehalten

Muster:Modifier.composed mit animate*AsState.

Migrationspfad:animate*AsState kann in einen benutzerdefinierten Modifikator unterteilt werden, der die Lebenszyklus-Callbacks überwacht und einen Animatable-Zustand enthält:

// ❌ BAD: Using Modifier.composed to save an animation state.
fun Modifier.fadeInOnHoverComposed(isHovered: Boolean): Modifier =
    composed {
        val alpha by
            animateFloatAsState(
                targetValue = if (isHovered) 1f else 0.4f,
                animationSpec = tween(durationMillis = 300),
                label = "alphaAnimation",
            )

        Modifier.graphicsLayer { this.alpha = alpha }
    }

// ✅ GOOD: Animation state can be saved in Modifier.Node like other types of stateful implementations.
fun Modifier.fadeInOnHover(isHovered: Boolean): Modifier =
    this.then(FadeInOnHoverElement(isHovered))

private data class FadeInOnHoverElement(val isHovered: Boolean) :
    ModifierNodeElement<FadeInOnHoverNode>() {
    override fun create(): FadeInOnHoverNode = FadeInOnHoverNode(isHovered)

    override fun update(node: FadeInOnHoverNode) {
        node.update(isHovered)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "fadeInOnHover"
        properties["isHovered"] = isHovered
    }
}

private class FadeInOnHoverNode(var isHovered: Boolean) : Modifier.Node(), LayoutModifierNode {
    // 1. Persistent Animatable field on the Node instance
    private val alphaAnimatable = Animatable(if (isHovered) 1f else 0.4f)

    override fun onAttach() {
        super.onAttach()
        startAnimation(isHovered)
    }

    // 2. Trigger animation imperatively when `isHovered` argument changes
    fun update(isHovered: Boolean) {
        if (this.isHovered != isHovered) {
            this.isHovered = isHovered
            if (isAttached) {
                startAnimation(isHovered)
            }
        }
    }

    private fun startAnimation(hovered: Boolean) {
        val targetAlpha = if (hovered) 1f else 0.4f
        // Use Node's built-in coroutineScope
        coroutineScope.launch {
            alphaAnimatable.animateTo(
                targetValue = targetAlpha,
                animationSpec = tween(durationMillis = 300),
            )
        }
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        return layout(placeable.width, placeable.height) {
            // Read current animation value during layout placement layer
            placeable.placeWithLayer(0, 0) { alpha = alphaAnimatable.value }
        }
    }
}