انتقال از 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.composed از Modifier.Node استفاده کنید، زیرا Modifier.Node نحوه مدیریت وضعیت در اصلاح‌کننده‌ها را بهبود می‌بخشد. ‫Modifier.Node یک شیء با وضعیت و ماندگاری طولانی است که یک‌بار در هر Modifier.Element اعمال‌شده بر LayoutNode ایجاد می‌شود و به‌جای اینکه در هر گذر ازطریق ترکیب دوباره مادی‌سازی شود، در ترکیب مجدد باقی می‌ماند. برای اطلاعات بیشتر درباره اینکه چرا و چگونه Modifier.Node را طراحی کردیم، به کاوش عمیق «اصلاح‌کننده‌های نوشتن» مراجعه کنید.

این سند نحوه انتقال از Modifier.composed به Modifier.Node را شرح می‌دهد. برای کسب اطلاعات بیشتر درباره استفاده عمومی از این API، به پیاده‌سازی رفتار اصلاح‌گر سفارشی بااستفاده از Modifier.Node مراجعه کنید.

مزایای عملکرد Modifier.Node

استفاده از Modifier.composed چندین گلوگاه عملکردی اساسی را معرفی می‌کند:

  • سربار مدیریت حالت: مدیریت حالت در این محدوده به remember فراخوانی و شیء حالت لحظه‌ای نیاز دارد که جدول جایگاه را با گروه‌های ترکیب غیرضروری متورم می‌کند و فشار حافظه را افزایش می‌دهد.
  • دسترسی به چرخه حیات پرهزینه: دسترسی به چرخه حیات اصلاح‌گر نیازمند استفاده از جلوه‌هایی مثل DisposableEffect است که به‌سرعت کار موردنیاز برای موارد استفاده ساده‌تر را افزایش می‌دهد.
  • عدم امکان رد کردن: چون لامبدای ارسال‌شده به composed یک Modifier برمی‌گرداند، کامپایلر Compose نمی‌تواند آن را به‌عنوان قابل رد کردن علامت‌گذاری کند و مجبور می‌شود هر زمان چیدمان بازآفرینی می‌شود، آن را دوباره اجرا کند.
  • برابری و حافظه‌سازی شکسته: چون تابع افزونه بیرونی خودش @Composable نیست، کامپایلر نمی‌تواند لامبدای داخلی را حافظه‌سازی کند، که منجر به تخصیص لامبدای تازه در هر فراخوانی می‌شود. این عدم حافظه‌سازی مستقیماً برابری اصلاح‌کننده (equals) را نقض می‌کند، زیرا ComposedModifier لامبداها را براساس مرجع مقایسه می‌کند. درنتیجه، «ترکیب» اصلاح‌کننده را به‌عنوان تغییریافته در هر قاب درنظر می‌گیرد، حتی اگر پارامترها ایستا باشند.
  • بدون انتشار تغییر هوشمند: بدون ردیابی پارامتر ترکیبی سطح بالا، هیچ راهی برای مقایسه ورودی‌های جدید با ورودی‌های قبلی برای انتشار تغییر هوشمند وجود ندارد.

به‌طورکلی، شکل Modifier.composed API نوشتن کد پرهزینه را تشویق می‌کند و مانع از اعمال بهینه‌سازی‌های اصلاح‌گر اضافی توسط زمان اجرای 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 }
        }
    }
}