Как перейти с 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. Подробную информацию об использовании этого API можно найти в статье Как реализовать поведение пользовательского модификатора с помощью Modifier.Node.

Преимущества производительности Modifier.Node

Использование Modifier.composed приводит к нескольким фундаментальным проблемам с производительностью:

  • Избыточное управление состоянием. Управление состоянием в этой области требует вызовов remember и объектов состояния снимка, что увеличивает таблицу слотов за счет ненужных групп композиции и повышает нагрузку на память.
  • Ресурсоемкий доступ к жизненному циклу. Чтобы получить доступ к жизненному циклу модификатора, нужно использовать эффекты, например DisposableEffect, что значительно усложняет работу в простых случаях.
  • Отсутствие возможности пропуска. Поскольку лямбда-выражение, переданное в composed, возвращает Modifier, компилятор Compose не может пометить его как пропускаемое, что приводит к его повторному выполнению при каждой рекомпозиции макета.
  • Нарушение мемоизации и равенства. Поскольку внешняя функция расширения сама по себе не является @Composable, компилятор не может мемоизировать внутреннюю лямбду, что приводит к выделению новой лямбды при каждом вызове. Отсутствие мемоизации напрямую нарушает равенство модификаторов (equals), поскольку ComposedModifier сравнивает лямбда-выражения по ссылке. Следовательно, Compose рассматривает модификатор как изменяется в каждом кадре, даже если параметры статичны.
  • Отсутствие умного распространения изменений. Без отслеживания параметров composable-функций верхнего уровня невозможно сравнить новые входные данные с предыдущими для умного распространения изменений.

В целом структура 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-функции, не связанной с макетом

Шаблон. Модификатору нужен доступ к функции, которая аннотирована с помощью @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 }
        }
    }
}