העברה מ-Modifier.composed אל Modifier.Node

‫Modifier.composed נוסף ב-Compose 1.0 כדי לאפשר לכם לגשת לרכיבי קומפוזיציה מתוך שינויים. לדוגמה, אחד מתרחישי השימוש העיקריים הוא יצירת משנה מצב שזוכר את המצב המקומי ומשתף אותו עם משנים אחרים במפעל Modifier.composed:

// ❌ 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.Node במקום ב-Modifier.composed, כי Modifier.Node משפר את אופן ניהול המצב בתוך מאפייני השינוי. ‫Modifier.Node הוא אובייקט עם שמירת מצב (stateful) לטווח ארוך שנוצר פעם אחת לכל Modifier.Element שמוחל על LayoutNode, והוא שורד את הרה-קומפוזיציה במקום להיווצר מחדש באמצעות קומפוזיציה בכל מעבר. למידע נוסף על הסיבות לעיצוב של Modifier.Node ועל אופן העיצוב, אפשר לעיין במאמר מידע מעמיק על Compose Modifiers.

במאמר הזה מוסבר איך לבצע מיגרציה מ-Modifier.composed אל Modifier.Node. מידע נוסף על שימוש כללי ב-API הזה זמין במאמר הטמעה של התנהגות מותאמת אישית של מקש צירוף באמצעות Modifier.Node.

יתרונות הביצועים של Modifier.Node

השימוש ב-Modifier.composed יוצר כמה צווארי בקבוק בביצועים:

  • תקורה של ניהול מצב: ניהול מצב בהיקף הזה דורש remember קריאות ואובייקטים של מצב תמונת מצב, מה שמנפח את טבלת המשבצות עם קבוצות קומפוזיציה מיותרות ומגדיל את העומס על הזיכרון.
  • גישה יקרה למחזור החיים: כדי לגשת למחזור החיים של ה-modifier צריך להשתמש באפקטים כמו DisposableEffect, מה שמגדיל במהירות את העבודה שנדרשת לתרחישי שימוש פשוטים יותר.
  • אי אפשרות דילוג: מכיוון שפונקציית ה-lambda שמועברת אל composed מחזירה Modifier, קומפיילר ה-Compose לא יכול לסמן אותה כפונקציה שאפשר לדלג עליה, ולכן הוא מאלץ אותה לפעול מחדש בכל פעם שהפריסה מורכבת מחדש.
  • ממויזציה ושוויוניות שבורות: מכיוון שפונקציית התוסף החיצונית עצמה היא לא @Composable, הקומפיילר לא יכול לבצע ממויזציה של פונקציית ה-lambda הפנימית, ולכן מוקצים הקצאות חדשות של lambda בכל קריאה. היעדר הממויזציה הזה גורם לשבירה ישירה של שוויון משנים (equals), כי ComposedModifier משווה למבדות לפי הפניה. לכן, Compose מתייחס למאפיין כאל שינוי בכל פריים, גם כשהפרמטרים סטטיים.
  • אין העברה חכמה של שינויים: בלי מעקב אחרי פרמטרים שאפשר להרכיב ברמה העליונה, אין דרך להשוות בין קלט חדש לקלט קודם כדי להעביר שינויים בצורה חכמה.

בסך הכול, הצורה של Modifier.composed API מעודדת כתיבת קוד יקר ומונעת מזמן הריצה של Compose להחיל אופטימיזציות נוספות של modifier.

השלבים העיקריים להעברה

בדוגמה הבאה מוצג מגביל מותאם אישית טיפוסי שהוטמע באמצעות Modifier.composed. מידע נוסף זמין במאמר הטמעה של התנהגות שונה של שינוי באמצעות Modifier.Node.

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. יצירת Modifier.Node (או 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. מטמיעים ממשק API עזר אחד או יותר של Modifier.Node, בהתאם למה שנדרש לשינוי המותאם אישית (לדוגמה, PointerInputModifierNode אם נדרשת גישה לממשקי API של קלט מצביע):

    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. יוצרים ModifierNodeElement שיוצר ומעדכן את הצומת המותאם אישית:

    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. מעדכנים את הפקטורי (factory) של מקש הצירוף כך שיפנה אל ModifierNodeElement:

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

מתכונים נפוצים להעברה

בדוגמאות הבאות מוצגות הדרכים להעביר תבניות נפוצות מ-Modifier.composed למפעלים של משני Modifier.Node או @Composable.

גישה ל-CompositionLocal

תבנית: קריאה של CompositionLocal יחיד, כמו LocalDensity, ‏ Theme או LocalView.

מסלול ההעברה: מסמנים את התוסף ב-@Composable. יש הבדל סמנטי בין שימוש במפעל של משנה composed לבין שימוש במפעל של משנה @Composable כדי לגשת אל CompositionLocal – במפעל של משנה @Composable, הערכים של CompositionLocal נפתרים באתר הקריאה של המפעל של המשנה. אם זו לא ההתנהגות הרצויה, צריך להשתמש בהטמעה מותאמת אישית של Modifier.Node שקוראת את CompositionLocals באמצעות CompositionLocalConsumerModifierNode.

מידע נוסף זמין במאמר יצירת משנה בהתאמה אישית באמצעות מפעל משנים של קומפוזיציה.

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

תבנית: קריאת CompositionLocal שאולי יחול על משנה עוקב.

נתיב ההעברה: יוצרים Modifier.Node בהתאמה אישית שמטמיע את CompositionLocalConsumerModifierNode ומשלב את כל היכולות של שינויי המאפיינים.

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

גישה לפונקציה קומפוזבילית שאינה פונקציית פריסה

תבנית: לשינוי צריך להיות גישה לפונקציה שמסומנת ב-@Composable ומחזירה אובייקט (לדוגמה, colorResource או ScrollableDefaults.flingBehavior).

נתיב ההעברה: מוסיפים את התג @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)
}

גישה להיקף של קורוטינה

תבנית: נעשה שימוש ב-Modifier.composed כדי להפעיל את rememberCoroutineScope כדי לגשת לאובייקט coroutineScope להפעלת קורוטינות.

נתיב העברה: משתמשים בModifier.Node מותאם אישית, שיש לו מאפיין coroutineScope שקשור למחזור החיים של מקש הצירוף (כמו rememberCoroutineScope בתוך 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()
    }
}

שמירת מצב

תבנית: שימוש ב-remember ב-Modifier.composed כדי לשמור את המצב בין הרכבות מחדש.

נתיב ההעברה: Modifier.Node נבנה כך שיחזיק את הסטטוס באותו אופן. אפשר לשמור את המצב בתוך מופע, בדיוק כמו כל מאפיין אחר של מחלקה, עם מחזור חיים ברור יותר:

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

שימוש באפקט

תבנית: שימוש באפקטים כדי להפעיל פעולות שקשורות למחזור החיים של ההרכבה (לדוגמה, כש-Modifier.composed נכנס או יוצא מההרכבה).

נתיב ההעברה: ל-Modifier.Node יש קריאות חוזרות ברורות למחזור החיים שאפשר להשתמש בהן כדי לבצע את אותן פעולות. לדוגמה, בדרך כלל אפשר להחליף את LaunchedEffect בשימוש ב-coroutineScope בתוך השיטה Modifier.Node onAttach:

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

החזקת מצב אנימציה

תבנית: Modifier.composed באמצעות animate*AsState.

נתיב ההעברה: אפשר לחלק את animate*AsState למשנה מותאם אישית שעוקב אחרי קריאות חוזרות (callback) של מחזור החיים ומחזיק במצב Animatable:

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