النقل من 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 هو عنصر طويل الأجل ذو حالة يتم إنشاؤه مرة واحدة لكل Modifier.Element يتم تطبيقه على LayoutNode، ويستمر في عملية إعادة التركيب بدلاً من إعادة إنشائه من خلال التركيب في كل مرة. لمزيد من المعلومات حول سبب وكيفية تصميم Modifier.Node، يُرجى الاطّلاع على نظرة متعمّقة على معدِّلات Compose.

يوضّح هذا المستند كيفية نقل البيانات من Modifier.composed إلى Modifier.Node. لمزيد من المعلومات حول الاستخدام العام لواجهة برمجة التطبيقات هذه، يُرجى الاطّلاع على تنفيذ سلوك المُعدِّل المخصّص باستخدام Modifier.Node.

مزايا الأداء في Modifier.Node

يؤدي استخدام Modifier.composed إلى حدوث العديد من المشاكل الأساسية في الأداء:

  • عبء إدارة الحالة: تتطلّب إدارة الحالة في هذا النطاق استدعاءات remember وعناصر حالة اللقطة، ما يؤدي إلى تضخيم جدول الخانات بمجموعات تركيب غير ضرورية وزيادة الضغط على الذاكرة.
  • الوصول إلى مراحل النشاط المكلفة: يتطلب الوصول إلى مراحل النشاط الخاصة بالمعدِّل استخدام تأثيرات مثل DisposableEffect، ما يزيد بسرعة من العمل المطلوب لحالات الاستخدام الأبسط.
  • عدم إمكانية التخطّي: بما أنّ دالة lambda التي تم تمريرها إلى composed تعرض Modifier، لا يمكن لبرنامج تجميع Compose وضع علامة عليها كقابلة للتخطّي، ما يفرض إعادة تنفيذها كلما تمت إعادة إنشاء التنسيق.
  • تعطُّل التخزين المؤقت والمساواة: بما أنّ دالة الإضافة الخارجية نفسها ليست @Composable، لا يمكن لبرنامج التجميع تخزين دالة lambda الداخلية مؤقتًا، ما يؤدي إلى تخصيصات جديدة لدالة lambda في كل استدعاء. يؤدي عدم استخدام تقنية التخزين المؤقت إلى كسر مساواة المُعدِّل (equals) مباشرةً، لأنّ ComposedModifier يقارن دوال lambda حسب المرجع. وبالتالي، يتعامل Compose مع المُعدِّل على أنّه تغيّر في كل إطار حتى عندما تكون المَعلمات ثابتة.
  • عدم نشر التغييرات الذكية: بدون تتبُّع المَعلمات المركّبة ذات المستوى الأعلى، لا توجد طريقة لمقارنة المدخلات الجديدة بالمدخلات السابقة لنشر التغييرات الذكية.

بشكل عام، يشجّع شكل واجهة برمجة التطبيقات Modifier.composed على كتابة رموز برمجية مكلفة ويمنع وقت تشغيل Compose من تطبيق تحسينات إضافية على المُعدِّلات.

الخطوات الأساسية لنقل البيانات

يعرض المثال التالي معدِّلاً مخصّصًا نموذجيًا تم تنفيذه باستخدام 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. نفِّذ واجهة برمجة تطبيقات مساعدة واحدة أو أكثر من واجهات برمجة التطبيقات في Modifier.Node، وذلك حسب احتياجات المُعدِّل المخصّص (على سبيل المثال، 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. أنشئ 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. عدِّل دالة إنشاء المُعدِّل لتشير إلى 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 يقرأ CompositionLocal باستخدام 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 إلى مُعدِّل مخصّص يراقب عمليات الاستدعاء في إحدى مراحل النشاط ويحتفظ بحالة 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 }
        }
    }
}