Как создать собственные модификаторы

Compose предоставляет множество модификаторов для распространенных действий, но вы также можете создавать собственные.

Модификаторы состоят из нескольких частей:

  • Фабрика модификаторов
    • Это функция расширения для Modifier, которая предоставляет идиоматический API для вашего модификатора и позволяет объединять модификаторы в цепочки. Фабрика модификаторов создает элементы модификаторов, которые Compose использует для изменения интерфейса.
  • Элемент модификатора
    • Здесь можно реализовать поведение модификатора.

Существует несколько способов реализовать пользовательский модификатор в зависимости от требуемой функциональности. Часто самый простой способ реализовать специальный модификатор – это создать специальную фабрику модификаторов, которая объединяет другие уже определенные фабрики модификаторов. Если вам нужны более сложные настройки, используйте элемент modifier с помощью API Modifier.Node, которые имеют более низкий уровень, но предоставляют больше возможностей.

Как объединять существующие модификаторы

Часто можно создавать собственные модификаторы на основе существующих. Например, Modifier.clip() реализован с помощью модификатора graphicsLayer. Эта стратегия использует существующие элементы модификатора, а вы предоставляете собственную фабрику модификаторов.

Прежде чем создавать собственный модификатор, проверьте, можно ли использовать ту же стратегию.

fun Modifier.clip(shape: Shape) = graphicsLayer(shape = shape, clip = true)

Если вы часто используете одну и ту же группу модификаторов, вы можете объединить их в собственный модификатор:

fun Modifier.myBackground(color: Color) = padding(16.dp)
    .clip(RoundedCornerShape(8.dp))
    .background(color)

Как создать собственный модификатор с помощью фабрики модификаторов

Вы также можете создать собственный модификатор, используя composable-функцию, чтобы передавать значения в существующий модификатор. Это называется фабрикой модификаторов.

Использование фабрики модификаторов позволяет создавать модификаторы с помощью API Compose более высокого уровня, например animate*AsState и других API анимации на основе состояния Compose. Например, в следующем фрагменте кода показан модификатор, который анимирует изменение альфа-канала при включении или отключении:

@Composable
fun Modifier.fade(enable: Boolean): Modifier {
    val alpha by animateFloatAsState(if (enable) 0.5f else 1.0f)
    return this then Modifier.graphicsLayer { this.alpha = alpha }
}

Если ваш специальный модификатор – это удобный метод для предоставления значений по умолчанию из CompositionLocal, проще всего реализовать его с помощью фабрики модификаторов, которые можно комбинировать:

@Composable
fun Modifier.fadedBackground(): Modifier {
    val color = LocalContentColor.current
    return this then Modifier.background(color.copy(alpha = 0.5f))
}

Однако у этого подхода есть некоторые ограничения, о которых рассказывается в следующих разделах.

Значения CompositionLocal определяются в месте вызова фабрики модификаторов.

При создании специального модификатора с помощью фабрики составных модификаторов локальные переменные композиции получают значение из дерева композиции, где они создаются, а не используются. Это может привести к неожиданным результатам. Например, рассмотрим пример с локальным модификатором композиции, который мы приводили ранее. Его можно реализовать немного иначе, используя composable-функцию:

@Composable
fun Modifier.myBackground(): Modifier {
    val color = LocalContentColor.current
    return this then Modifier.background(color.copy(alpha = 0.5f))
}

@Composable
fun MyScreen() {
    CompositionLocalProvider(LocalContentColor provides Color.Green) {
        // Background modifier created with green background
        val backgroundModifier = Modifier.myBackground()

        // LocalContentColor updated to red
        CompositionLocalProvider(LocalContentColor provides Color.Red) {

            // Box will have green background, not red as expected.
            Box(modifier = backgroundModifier)
        }
    }
}

Если вы хотите, чтобы модификатор работал иначе, используйте пользовательский Modifier.Node, поскольку локальные переменные композиции правильно разрешаются в месте использования и могут быть безопасно подняты.

Модификаторы composable-функций никогда не пропускаются

Модификаторы фабрики, которые можно комбинировать, никогда не пропускаются, поскольку composable-функции, которые возвращают значения, нельзя пропустить. Это означает, что функция модификатора будет вызываться при каждой повторной композиции, что может быть дорогостоящим, если композиция выполняется часто.

Модификаторы composable-функций должны вызываться внутри composable-функций

Как и все composable-функции, модификатор фабрики composable-функций должен вызываться из композиции. Это ограничивает область действия модификатора, поскольку он никогда не может быть вынесен за пределы композиции. В отличие от них, фабрики модификаторов, не поддерживающие композицию, можно вынести за пределы composable-функций, чтобы упростить повторное использование и повысить производительность:

val extractedModifier = Modifier.background(Color.Red) // Hoisted to save allocations

@Composable
fun Modifier.composableModifier(): Modifier {
    val color = LocalContentColor.current.copy(alpha = 0.5f)
    return this then Modifier.background(color)
}

@Composable
fun MyComposable() {
    val composedModifier = Modifier.composableModifier() // Cannot be extracted any higher
}

Как реализовать собственное поведение модификатора с помощью Modifier.Node

Modifier.Node – это API более низкого уровня для создания модификаторов в Compose. Это тот же API, который Compose использует для реализации собственных модификаторов, и самый эффективный способ создания пользовательских модификаторов.

Как реализовать специальный модификатор с помощью Modifier.Node

Чтобы реализовать специальный модификатор с помощью Modifier.Node, выполните следующие действия:

  • Реализация Modifier.Node, в которой хранится логика и состояние модификатора.
  • ModifierNodeElement, который создает и обновляет экземпляры узлов модификаторов.
  • Необязательный модификатор, как описано выше.

Классы ModifierNodeElement не имеют состояния, и при каждой перекомпоновке выделяются новые экземпляры, тогда как классы Modifier.Node могут иметь состояние и сохраняться при нескольких перекомпоновках, а также могут быть повторно использованы.

В следующем разделе описаны все части модификатора и приведен пример создания модификатора для рисования круга.

Modifier.Node

Реализация Modifier.Node (в этом примере CircleNode) реализует функциональность вашего пользовательского модификатора.

// Modifier.Node
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

В этом примере круг будет нарисован цветом, переданным в функцию модификатора.

Узел реализует интерфейс Modifier.Node, а также ноль или более типов узлов. Существуют разные типы узлов, которые зависят от того, какие функции требуются модификатору. В приведенном выше примере нужно, чтобы объект мог рисовать, поэтому он реализует интерфейс DrawModifierNode, который позволяет переопределить метод draw.

Доступны следующие типы:

Узел

Использование

Пример ссылки

LayoutModifierNode

Modifier.Node, который изменяет способ измерения и размещения контента.

Пример

DrawModifierNode

Modifier.Node, который рисует в пространстве макета.

Пример

CompositionLocalConsumerModifierNode

Реализация этого интерфейса позволяет Modifier.Node читать локальные переменные композиции.

Пример

SemanticsModifierNode

Modifier.Node, добавляющий семантические пары "ключ-значение" для тестирования, обеспечения доступности и других целей.

Пример

PointerInputModifierNode

Modifier.Node, получающий PointerInputChanges.

Пример

ParentDataModifierNode

Modifier.Node, предоставляющий данные родительскому макету.

Пример

LayoutAwareModifierNode

Modifier.Node, который получает обратные вызовы onMeasured и onPlaced.

Пример

GlobalPositionAwareModifierNode

Modifier.Node, который получает обратный вызов onGloballyPositioned с окончательным LayoutCoordinates макета, когда глобальное положение контента может измениться.

Пример

ObserverModifierNode

Modifier.Node, которые реализуют ObserverNode, могут предоставить собственную реализацию onObservedReadsChanged, которая будет вызываться в ответ на изменения объектов моментального снимка, прочитанных в блоке observeReads.

Пример

DelegatingNode

Modifier.Node, который может делегировать работу другим экземплярам Modifier.Node.

Это может быть полезно, если вы хотите объединить несколько реализаций узлов в одну.

Пример

TraversableNode

Позволяет классам Modifier.Node перемещаться вверх и вниз по дереву узлов для классов одного типа или для определенного ключа.

Пример

Узлы автоматически становятся недействительными, когда вызывается обновление соответствующего элемента. Поскольку в нашем примере используется объект DrawModifierNode, при каждом обновлении элемента вызывается функция перерисовки, и цвет узла обновляется. Вы можете отключить автоматическую деактивацию узлов. Подробнее об этом рассказывается в разделе Как отключить автоматическую деактивацию узлов.

ModifierNodeElement

ModifierNodeElement – это неизменяемый класс, который содержит данные для создания или обновления специального модификатора:

// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)

    override fun update(node: CircleNode) {
        node.color = color
    }
}

В реализациях ModifierNodeElement необходимо переопределить следующие методы:

  1. create – функция, которая создает экземпляр узла модификатора. Этот метод вызывается для создания узла при первом применении модификатора. Обычно это означает создание узла и его настройку с помощью параметров, переданных в фабрику модификаторов.
  2. update – эта функция вызывается, когда модификатор указан в том же месте, где уже есть узел, но одно из его свойств изменилось. Это определяется методом equals класса. Ранее созданный узел модификатора отправляется в качестве параметра в вызов update. На этом этапе вам нужно обновить свойства узлов, чтобы они соответствовали измененным параметрам. Возможность повторно использовать узлы – ключевой фактор повышения производительности, которое обеспечивает Modifier.Node. Поэтому в методе update нужно обновлять существующий узел, а не создавать новый. В нашем примере с кругом цвет узла изменился.

Кроме того, в реализациях ModifierNodeElement необходимо реализовать equals и hashCode. update будет вызываться только в том случае, если сравнение с предыдущим элементом вернет значение false.

В приведенном выше примере для этого используется класс данных. Эти методы используются, чтобы проверить, нужно ли обновлять узел. Если у вашего элемента есть свойства, которые не влияют на необходимость обновления узла, или вы хотите избежать классов данных по причинам совместимости, вы можете вручную реализовать equals и hashCode, например элемент модификатора отступов.

Фабрика модификаторов

Это общедоступный API модификатора. В большинстве случаев элемент modifier создается и добавляется в цепочку модификаторов следующим образом:

// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)

Полный пример

Эти три части вместе образуют пользовательский модификатор для рисования круга с помощью API Modifier.Node:

// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)

// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)

    override fun update(node: CircleNode) {
        node.color = color
    }
}

// Modifier.Node
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

Примеры использования Modifier.Node

При создании специальных модификаторов с помощью Modifier.Node могут возникнуть следующие ситуации:

Нет параметров

Если у модификатора нет параметров, его не нужно обновлять, а значит, он не должен быть классом данных. Ниже приведен пример реализации модификатора, который добавляет к композиции фиксированный размер отступа.

fun Modifier.fixedPadding() = this then FixedPaddingElement

data object FixedPaddingElement : ModifierNodeElement<FixedPaddingNode>() {
    override fun create() = FixedPaddingNode()
    override fun update(node: FixedPaddingNode) {}
}

class FixedPaddingNode : LayoutModifierNode, Modifier.Node() {
    private val PADDING = 16.dp

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val paddingPx = PADDING.roundToPx()
        val horizontal = paddingPx * 2
        val vertical = paddingPx * 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(paddingPx, paddingPx)
        }
    }
}

Местные композиции

Модификаторы Modifier.Node не отслеживают изменения объектов состояния Compose, например CompositionLocal. Преимущество модификаторов Modifier.Node перед модификаторами, созданными с помощью фабрики, заключается в том, что они могут считывать значение локальной композиции из того места в дереве интерфейса, где используется модификатор, а не где он выделен, с помощью currentValueOf.

Однако экземпляры узлов модификаторов не отслеживают изменения состояния автоматически. Чтобы автоматически реагировать на локальные изменения композиции, вы можете прочитать ее текущее значение в области действия:

В этом примере значение LocalContentColor используется для отрисовки фона определенного цвета. Поскольку ContentDrawScope отслеживает изменения моментальных снимков, при изменении значения LocalContentColor происходит автоматическая перерисовка:

class BackgroundColorConsumerNode :
    Modifier.Node(),
    DrawModifierNode,
    CompositionLocalConsumerModifierNode {
    override fun ContentDrawScope.draw() {
        val currentColor = currentValueOf(LocalContentColor)
        drawRect(color = currentColor)
        drawContent()
    }
}

Чтобы реагировать на изменения состояния за пределами области действия и автоматически обновлять модификатор, используйте ObserverModifierNode.

Например, Modifier.scrollable использует этот метод, чтобы отслеживать изменения в LocalDensity. Ниже приведен упрощенный пример:

class ScrollableNode :
    Modifier.Node(),
    ObserverModifierNode,
    CompositionLocalConsumerModifierNode {

    // Place holder fling behavior, we'll initialize it when the density is available.
    val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity))

    override fun onAttach() {
        updateDefaultFlingBehavior()
        observeReads { currentValueOf(LocalDensity) } // monitor change in Density
    }

    override fun onObservedReadsChanged() {
        // if density changes, update the default fling behavior.
        updateDefaultFlingBehavior()
    }

    private fun updateDefaultFlingBehavior() {
        val density = currentValueOf(LocalDensity)
        defaultFlingBehavior.flingDecay = splineBasedDecay(density)
    }
}

Как анимировать модификатор

Реализации Modifier.Node имеют доступ к coroutineScope. Это позволяет использовать анимируемые API Compose. Например, этот фрагмент кода изменяет CircleNode, показанный ранее, чтобы он появлялся и исчезал несколько раз:

class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
    private lateinit var alpha: Animatable<Float, AnimationVector1D>

    override fun ContentDrawScope.draw() {
        drawCircle(color = color, alpha = alpha.value)
        drawContent()
    }

    override fun onAttach() {
        alpha = Animatable(1f)
        coroutineScope.launch {
            alpha.animateTo(
                0f,
                infiniteRepeatable(tween(1000), RepeatMode.Reverse)
            ) {
            }
        }
    }
}

Как передавать состояние между модификаторами с помощью делегирования

Модификаторы Modifier.Node могут делегировать задачи другим узлам. Это можно использовать в разных целях, например для извлечения общих реализаций из разных модификаторов или для обмена общим состоянием между модификаторами.

Пример базовой реализации узла модификатора, на который можно нажать, и который передает данные о взаимодействии:

class ClickableNode : DelegatingNode() {
    val interactionData = InteractionData()
    val focusableNode = delegate(
        FocusableNode(interactionData)
    )
    val indicationNode = delegate(
        IndicationNode(interactionData)
    )
}

Как отключить автоматическое аннулирование узлов

Узлы Modifier.Node автоматически становятся недействительными, когда соответствующие им узлы ModifierNodeElement вызывают обновление. Если вы используете сложные модификаторы, то можете отключить это поведение, чтобы более точно контролировать, когда модификатор делает фазы недействительными.

Это особенно полезно, если ваш модификатор изменяет и макет, и отрисовку. Если вы отключите автоматическую аннулирование, то сможете аннулировать отрисовку только при изменении связанных с ней свойств, например color. Это позволит избежать недействительности макета и повысить эффективность модификатора.

Ниже приведен гипотетический пример модификатора, у которого в качестве свойств используются лямбда-функции color, size и onClick. Этот модификатор делает недействительным только то, что необходимо, пропуская ненужные действия:

class SampleInvalidatingNode(
    var color: Color,
    var size: IntSize,
    var onClick: () -> Unit
) : DelegatingNode(), LayoutModifierNode, DrawModifierNode {
    override val shouldAutoInvalidate: Boolean
        get() = false

    private val clickableNode = delegate(
        ClickablePointerInputNode(onClick)
    )

    fun update(color: Color, size: IntSize, onClick: () -> Unit) {
        if (this.color != color) {
            this.color = color
            // Only invalidate draw when color changes
            invalidateDraw()
        }

        if (this.size != size) {
            this.size = size
            // Only invalidate layout when size changes
            invalidateMeasurement()
        }

        // If only onClick changes, we don't need to invalidate anything
        clickableNode.update(onClick)
    }

    override fun ContentDrawScope.draw() {
        drawRect(color)
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val size = constraints.constrain(size)
        val placeable = measurable.measure(constraints)
        return layout(size.width, size.height) {
            placeable.place(0, 0)
        }
    }
}