از 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ها را بهبود می‌بخشد. یک Modifier.Node یک شیء با طول عمر بالا و حالت‌پذیر است که یک بار به ازای هر Modifier.Element اعمال شده بر روی یک LayoutNode ایجاد می‌شود و به جای اینکه در هر بار ترکیب‌بندی دوباره ساخته شود، از ترکیب‌بندی مجدد جان سالم به در می‌برد. برای اطلاعات بیشتر در مورد اینکه چرا و چگونه Modifier.Node را طراحی کرده‌ایم، به بخش Compose Modifiers deep diver مراجعه کنید.

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

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

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

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

در مجموع، شکل API 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. بسته به نیاز اصلاح‌کننده سفارشی خود، یک یا چند 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. کارخانه اصلاح‌کننده را به‌روزرسانی کنید تا به 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 است که به چرخه حیات modifier گره خورده است (مانند 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 برای نگهداری state به همین روش ساخته شده است. state را می‌توان درون یک نمونه نگهداری کرد، درست مانند هر ویژگی کلاس دیگر با چرخه حیات واضح‌تر:

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