Modifier.composed থেকে Modifier.Node-এ স্থানান্তরিত করুন

Compose 1.0-এ Modifier.composed চালু করা হয়েছিল, যা আপনাকে মডিফায়ার থেকে কম্পোজিশনের উপাদানগুলো অ্যাক্সেস করার সুযোগ দেয়। উদাহরণস্বরূপ, এর একটি প্রধান ব্যবহার হলো এমন একটি স্টেটফুল মডিফায়ার তৈরি করা, যা লোকাল স্টেট মনে রাখে এবং 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 হলো একটি দীর্ঘস্থায়ী, স্টেটফুল অবজেক্ট যা একটি LayoutNode এ প্রয়োগ করা প্রতিটি Modifier.Element জন্য একবার তৈরি হয়, এবং এটি প্রতিটি ধাপে কম্পোজিশনের মাধ্যমে পুনরায় ম্যাটেরিয়ালাইজড না হয়ে রিকম্পোজিশন সহ্য করতে পারে। আমরা কেন এবং কীভাবে Modifier.Node ডিজাইন করেছি সে সম্পর্কে আরও তথ্যের জন্য, Compose Modifiers deep dive দেখুন।

এই ডকুমেন্টটিতে Modifier.composed থেকে Modifier.Node এ মাইগ্রেট করার পদ্ধতি বর্ণনা করা হয়েছে। এই API-এর সাধারণ ব্যবহার সম্পর্কে আরও তথ্যের জন্য, “Implement custom modifier behavior using Modifier.Node” দেখুন।

Modifier.Node এর কর্মক্ষমতাগত সুবিধা

Modifier.composed ব্যবহার করলে বেশ কিছু মৌলিক পারফরম্যান্সের প্রতিবন্ধকতা তৈরি হয়:

  • স্টেট ম্যানেজমেন্ট ওভারহেড: এই পরিসরে স্টেট ম্যানেজ করার জন্য remember কল এবং স্ন্যাপশট স্টেট অবজেক্টের প্রয়োজন হয়, যা অপ্রয়োজনীয় কম্পোজিশন গ্রুপ দিয়ে স্লট টেবিলকে স্ফীত করে এবং মেমোরির উপর চাপ বাড়ায়।
  • ব্যয়বহুল লাইফসাইকেল অ্যাক্সেস: মডিফায়ারের লাইফসাইকেল অ্যাক্সেস করার জন্য DisposableEffect এর মতো এফেক্ট ব্যবহার করতে হয়, যা অপেক্ষাকৃত সহজ ব্যবহারের ক্ষেত্রেও প্রয়োজনীয় কাজ দ্রুত বাড়িয়ে দেয়।
  • স্কিপ করার অযোগ্যতা: যেহেতু composed এ পাস করা ল্যাম্বডা একটি Modifier রিটার্ন করে, কম্পোজ কম্পাইলার এটিকে স্কিপেবল হিসেবে চিহ্নিত করতে পারে না, ফলে যখনই লেআউটটি রিকম্পোজ হয়, এটি পুনরায় এক্সিকিউট হতে বাধ্য হয়।
  • মেমোইজেশন এবং ইকুয়ালিটির ত্রুটি: যেহেতু বাইরের এক্সটেনশন ফাংশনটি নিজেই একটি @Composable নয়, তাই কম্পাইলার ভেতরের ল্যাম্বডাটিকে মেমোইজ করতে পারে না, যার ফলে প্রতিটি কলে নতুন করে ল্যাম্বডা অ্যালোকেশন করতে হয়। মেমোইজেশনের এই অভাব সরাসরি মডিফায়ারের ইকুয়ালিটি ( equals ) লঙ্ঘন করে, কারণ ComposedModifier রেফারেন্সের মাধ্যমে ল্যাম্বডাগুলোর তুলনা করে। ফলস্বরূপ, প্যারামিটারগুলো স্ট্যাটিক হলেও Compose প্রতিটি ফ্রেমে মডিফায়ারটিকে পরিবর্তিত হিসেবে গণ্য করে।
  • স্মার্ট পরিবর্তন প্রচারের অভাব: শীর্ষ-স্তরের কম্পোজেবল প্যারামিটার ট্র্যাকিং ছাড়া, স্মার্ট পরিবর্তন প্রচারের জন্য নতুন ইনপুটগুলোকে পূর্ববর্তীগুলোর সাথে তুলনা করার কোনো উপায় নেই।

সামগ্রিকভাবে, 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 এর এক বা একাধিক সহায়ক API প্রয়োগ করুন (উদাহরণস্বরূপ, যদি পয়েন্টার ইনপুট API-গুলিতে অ্যাক্সেসের প্রয়োজন হয় তবে 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 মডিফায়ার ফ্যাক্টরিতে সাধারণ প্যাটার্নগুলি স্থানান্তর করা যায়।

একটি কম্পোজিশনলোকাল অ্যাক্সেস করুন

প্যাটার্ন: LocalDensity , Theme , বা LocalView এর মতো একটিমাত্র CompositionLocal পড়া।

মাইগ্রেশন পাথ: মডিফায়ারটিকে @Composable দিয়ে চিহ্নিত করুন। একটি CompositionLocal অ্যাক্সেস করার জন্য composed মডিফায়ার এবং @Composable মডিফায়ার ফ্যাক্টরি ব্যবহারের মধ্যে একটি অর্থগত পার্থক্য রয়েছে — @Composable ফ্যাক্টরির ক্ষেত্রে, CompositionLocal ভ্যালুগুলো মডিফায়ার ফ্যাক্টরির কল সাইটে রিজলভ করা হয়। যদি এটি কাঙ্ক্ষিত আচরণ না হয়, তবে একটি কাস্টম Modifier.Node ইমপ্লিমেন্টেশন ব্যবহার করুন যা CompositionLocalConsumerModifierNode ব্যবহার করে CompositionLocal গুলো রিড করে।

আরও তথ্যের জন্য, একটি কম্পোজেবল মডিফায়ার ফ্যাক্টরি ব্যবহার করে একটি কাস্টম মডিফায়ার তৈরি করুন দেখুন।

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

একটি কো-রুটিন স্কোপ অ্যাক্সেস করুন

প্যাটার্ন: কোরাউটিন চালু করার জন্য একটি coroutineScope ) অবজেক্ট অ্যাক্সেস করতে rememberCoroutineScope এক্সিকিউট করার জন্য Modifier.composed ব্যবহৃত হয়।

স্থানান্তরের উপায়: একটি কাস্টম Modifier.Node ব্যবহার করুন, যেটির একটি coroutineScope প্রপার্টি আছে যা মডিফায়ার লাইফসাইকেলের সাথে যুক্ত (যেমন Modifier.composed ভেতরের 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()
    }
}

একটি রাজ্য মনে রাখুন

প্যাটার্ন: পুনর্গঠনের সময় স্টেট সংরক্ষণ করতে Modifier.composed এ remember ব্যবহার করা।

স্থানান্তরের পথ: 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 সুস্পষ্ট লাইফসাইকেল কলব্যাক রয়েছে যা একই অপারেশনগুলো সম্পাদন করতে ব্যবহার করা যেতে পারে। উদাহরণস্বরূপ, Modifier.Node onAttach মেথডের ভিতরে coroutineScope ব্যবহার করে সাধারণত একটি LaunchedEffect প্রতিস্থাপন করা যায়:

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

একটি অ্যানিমেশন অবস্থা ধরে রাখুন

প্যাটার্ন: animate*AsState ব্যবহার করে Modifier.composed ।

স্থানান্তরের পথ: 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 }
        }
    }
}