Modifier.composed'dan Modifier.Node'a taşıma

Modifier.composed, değiştiricilerden kompozisyon öğelerine erişmenizi sağlamak için Compose 1.0'da kullanıma sunuldu. Örneğin, önemli bir kullanım alanı, yerel durumu hatırlayan ve Modifier.composed fabrikasındaki diğer değiştiricilerle paylaşan durum bilgisi olan bir değiştirici oluşturmaktır:

// ❌ 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
        )
}

Modifier.composed yerine Modifier.Node kullanın. Modifier.Node, durumun değiştiricilerde yönetilme şeklini iyileştirir. Modifier.Node, LayoutNode'ye uygulanan her Modifier.Element için bir kez oluşturulan, uzun ömürlü ve durum bilgisi olan bir nesnedir. Her geçişte kompozisyon aracılığıyla yeniden oluşturulmak yerine, yeniden oluşturma işleminden sonra da varlığını sürdürür. Modifier.Node'ı neden ve nasıl tasarladığımız hakkında daha fazla bilgi için Compose Modifiers deep dive başlıklı makaleyi inceleyin.

Bu belgede, Modifier.composed sürümünden Modifier.Node sürümüne nasıl geçileceği açıklanmaktadır. Bu API'nin genel kullanımı hakkında daha fazla bilgi için Modifier.Node kullanarak özel değiştirici davranışı uygulama başlıklı makaleyi inceleyin.

Modifier.Node'un performans avantajları

Modifier.composed kullanmak, performansla ilgili çeşitli temel darboğazlara yol açar:

  • Durum yönetimi ek yükü: Bu kapsamda durumu yönetmek için remember çağrıları ve anlık görüntü durumu nesneleri gerekir. Bu da yuva tablosunu gereksiz kompozisyon gruplarıyla şişirir ve bellek baskısını artırır.
  • Pahalı yaşam döngüsü erişimi: Değiştiricinin yaşam döngüsüne erişmek için DisposableEffect gibi efektlerin kullanılması gerekir. Bu da daha basit kullanım alanları için gereken işi hızla artırır.
  • Atlanabilirlik eksikliği: composed'ye iletilen lambda Modifier döndürdüğünden Compose derleyicisi bunu atlanabilir olarak işaretleyemez ve düzen yeniden oluşturulduğunda yeniden yürütülmeye zorlar.
  • Memoizasyon ve eşitlik bozulması: Dıştaki uzantı işlevi kendisi bir @Composable olmadığından derleyici, içteki lambda'yı memoize edemez. Bu da her çağrıda yeni lambda ayırmalarına neden olur. Bu ezberleme eksikliği, ComposedModifier referansa göre lambda'ları karşılaştırdığı için değiştirici eşitliğini (equals) doğrudan bozar. Bu nedenle, parametreler statik olsa bile Compose, değiştiriciyi her karede değiştirilmiş olarak değerlendirir.
  • Akıllı değişiklik yayma yok: Üst düzeyde birleştirilebilir parametre izleme olmadan, akıllı değişiklik yayma için yeni girişleri önceki girişlerle karşılaştırmanın bir yolu yoktur.

Genel olarak, Modifier.composed API şekli pahalı kod yazmayı teşvik eder ve Compose çalışma zamanının ek değiştirici optimizasyonları uygulamasını engeller.

Temel taşıma adımları

Aşağıdaki örnekte, Modifier.composed ile uygulanan tipik bir özel değiştirici gösterilmektedir. Daha fazla bilgi için Modifier.Node kullanarak özel değiştirici davranışı uygulama başlıklı makaleyi inceleyin.

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. Özel Modifier.Node (veya DelegatingNode) oluşturma:

    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. Özel değiştiricinizin ihtiyaçlarına bağlı olarak Modifier.Node'nın yardımcı API'lerinden birini veya daha fazlasını uygulayın (örneğin, işaretçi girişi API'lerine erişmesi gerekiyorsa PointerInputModifierNode):

    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. Özel düğümünüzü oluşturan ve güncelleyen bir ModifierNodeElement oluşturun:

    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. Değiştirici fabrikasını ModifierNodeElement'ya yönlendirecek şekilde güncelleyin:

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

Sık kullanılan taşıma tarifleri

Aşağıdaki tariflerde, yaygın kalıpların Modifier.composed'dan Modifier.Node veya @Composable değiştirici fabrikalarına nasıl taşınacağı gösterilmektedir.

CompositionLocal'e erişme

Desen: LocalDensity, Theme veya LocalView gibi tek bir CompositionLocal okuma.

Taşıma yolu: Değiştiriciyi @Composable ile işaretleyin. composed değiştiricisini ve @Composable değiştiricisi fabrikasını kullanarak CompositionLocal öğesine erişmek arasında anlamsal bir fark vardır. @Composable fabrikasıyla, CompositionLocal değerleri değiştirici fabrikasının çağrı sitesinde çözümlenir. Bu davranış amaçlanmıyorsa Modifier.Node uygulamasını kullanarak CompositionLocals okuyan özel bir CompositionLocalConsumerModifierNode uygulaması kullanın.

Daha fazla bilgi için Composable değiştirici fabrikası kullanarak özel değiştirici oluşturma başlıklı makaleyi inceleyin.

// ❌ 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()

Desen: Sonraki bir değiştiriciye uygulanabilecek bir CompositionLocal okunuyor.

Taşıma yolu: CompositionLocalConsumerModifierNode işlevini uygulayan ve tüm değiştiricilerin özelliklerini birleştiren özel bir Modifier.Node oluşturun.

// ❌ 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)
        }
    }
}

Düzen dışı bir composable işlevine erişme

Desen: Değiştiricinin, @Composable ile açıklama eklenmiş ve bir nesne (örneğin, colorResource veya ScrollableDefaults.flingBehavior) döndüren bir işleve erişmesi gerekir.

Taşıma yolu: Değiştiriciye @Composable ile açıklama ekleyin.

// ❌ 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)
}

Coroutine kapsamına erişme

Desen: Modifier.composed, eş yordamları başlatmak için coroutineScope nesnesine erişmek üzere rememberCoroutineScope'ü yürütmek için kullanılır.

Taşıma yolu: Değiştirici yaşam döngüsüne bağlı bir coroutineScope mülkü olan özel bir Modifier.Node kullanın (ör. Modifier.composed içinde rememberCoroutineScope):

// ❌ 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()
    }
}

Bir durumu hatırlama

Desen: Yeniden oluşturmalar arasında durumu kaydetmek için Modifier.composed içinde remember kullanma.

Taşıma yolu: Modifier.Node, durumu aynı şekilde koruyacak şekilde oluşturulmuştur. Durum, diğer sınıf özelliklerinde olduğu gibi bir örnek içinde tutulabilir. Ancak daha net bir yaşam döngüsü vardır:

// ❌ 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()
    }
}

Efekt kullanma

Kalp: Kompozisyon yaşam döngüsüne bağlı işlemleri yürütmek için efektleri kullanma (örneğin, Modifier.composed kompozisyona girdiğinde veya kompozisyondan çıktığında).

Taşıma yolu: Modifier.Node, aynı işlemlerin yürütülmesinde kullanılabilecek net yaşam döngüsü geri çağırmalarına sahiptir. Örneğin, LaunchedEffect genellikle Modifier.Node onAttach yöntemi içinde coroutineScope kullanılarak değiştirilebilir:

// ❌ 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) }
    }
}

Animasyon durumunu tutma

Desen: Modifier.composed, animate*AsState kullanılarak.

Taşıma yolu: animate*AsState, yaşam döngüsü geri çağırmalarını izleyen ve Animatable durumunu tutan özel bir değiştiriciye ayrılabilir:

// ❌ 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 }
        }
    }
}