Criar modificadores personalizados

O Compose oferece muitos modificadores para comportamentos comuns, mas você também pode criar seus próprios modificadores personalizados.

Os modificadores têm várias partes:

  • Uma fábrica de modificadores
    • Essa é uma função de extensão em Modifier, que fornece uma API idiomática para o modificador e permite que os modificadores sejam encadeados com facilidade. A fábrica de modificadores produz os elementos modificadores usados pelo Compose para modificar sua interface.
  • Um elemento modificador
    • É aqui que você pode implementar o comportamento do modificador.

Há várias maneiras de implementar um modificador personalizado, dependendo da funcionalidade necessária. Muitas vezes, a maneira mais fácil de implementar um modificador personalizado é implementar uma fábrica de modificadores personalizados que combina outras fábricas de modificadores já definidas. Se você precisar de um comportamento mais personalizado, implemente o elemento modificador usando as APIs Modifier.Node, que são de nível mais baixo, mas oferecem mais flexibilidade.

Encadear modificadores

Muitas vezes, é possível criar modificadores personalizados apenas usando modificadores existentes. Por exemplo, Modifier.clip() é implementado usando o modificador graphicsLayer. Essa estratégia usa elementos de modificador existentes, e você fornece sua própria fábrica de modificadores personalizados.

Antes de implementar seu próprio modificador personalizado, verifique se você pode usar a mesma estratégia.

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

Ou, se você perceber que está repetindo o mesmo grupo de modificadores com frequência, é possível agrupá-los no seu próprio modificador:

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

Criar um modificador personalizado usando uma fábrica de modificadores combináveis

Também é possível criar um modificador personalizado usando uma função combinável para transmitir valores a um modificador existente. Isso é conhecido como uma fábrica de modificadores combináveis.

O uso de uma fábrica de modificadores combináveis para criar um modificador também permite o uso de APIs de composição de nível mais alto, como animate*AsState e outras APIs de animação com suporte ao estado do Compose. Por exemplo, o snippet a seguir mostra um modificador que anima uma mudança Alfa quando ativado/desativado:

@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 }
}

Se o modificador personalizado for um método de conveniência para fornecer valores padrão de um CompositionLocal, a maneira mais fácil de implementar isso é usar uma fábrica de modificadores combináveis:

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

Essa abordagem tem algumas ressalvas detalhadas abaixo.

Os valores de CompositionLocal são resolvidos no site de chamada da fábrica de modificadores

Ao criar um modificador personalizado usando uma fábrica de modificadores combináveis, os locais de composição usam o valor da árvore de composição em que são criados, não usados. Isso pode levar a resultados inesperados. Por exemplo, considere o exemplo de modificador local de composição acima, implementado de maneira um pouco diferente usando uma função combinável:

@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)
        }
    }
}

Se não for assim que você espera que o modificador funcione, use um Modifier.Node personalizado, já que os locais de composição serão resolvidos corretamente no site de uso e poderão ser elevados com segurança.

Os modificadores de função combinável nunca são ignorados

Os modificadores de fábrica combináveis nunca são ignorados porque as funções combináveis que têm valores de retorno não podem ser ignoradas. Isso significa que a função do modificador será chamada em cada recomposição, o que pode ser caro se ela for refeita com frequência.

Os modificadores de função combinável precisam ser chamados em uma função combinável

Como todas as funções combináveis, um modificador de fábrica combinável precisa ser chamado dentro da composição. Isso limita para onde um modificador pode ser elevado, já que ele nunca pode ser elevado para fora da composição. Em comparação, as fábricas de modificadores não combináveis podem ser elevadas para fora das funções combináveis para permitir uma reutilização mais fácil e melhorar o desempenho:

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
}

Implementar o comportamento do modificador personalizado usando Modifier.Node

Modifier.Node é uma API de nível inferior para criar modificadores no Compose. Ela é a mesma API em que o Compose implementa os próprios modificadores e é a maneira mais eficiente de criar modificadores personalizados.

Implementar um modificador personalizado usando Modifier.Node

Há três partes para implementar um modificador personalizado usando Modifier.Node:

  • Uma implementação Modifier.Node que contém a lógica e o estado do modificador.
  • Um ModifierNodeElement que cria e atualiza instâncias de modificador de nó.
  • Uma fábrica de modificadores opcional, conforme detalhado acima.

As classes ModifierNodeElement não têm estado, e novas instâncias são alocadas em cada recomposição, enquanto as classes Modifier.Node podem ter estado, sobreviver em várias recomposições e até mesmo ser reutilizadas.

A seção a seguir descreve cada parte e mostra um exemplo de como criar um modificador personalizado para desenhar um círculo.

Modifier.Node

A implementação Modifier.Node (neste exemplo, CircleNode) implementa a funcionalidade do modificador personalizado.

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

Neste exemplo, ele desenha o círculo com a cor transmitida para a função modificadora.

Um nó implementa Modifier.Node e zero ou mais tipos de nó. Há diferentes tipos de nó com base na funcionalidade que o modificador exige. O exemplo acima precisa ser capaz de desenhar, então ele implementa DrawModifierNode, o que permite substituir o método de exibição.

Os tipos disponíveis são os seguintes:

Uso

Link de exemplo

LayoutModifierNode

Um Modifier.Node que muda a forma como o conteúdo agrupado é medido e disposto.

Amostra

DrawModifierNode

Um Modifier.Node que é desenhado no espaço do layout.

Amostra

CompositionLocalConsumerModifierNode

A implementação dessa interface permite que o Modifier.Node leia os locais de composição.

Amostra

SemanticsModifierNode

Um Modifier.Node que adiciona chave-valor de semântica para uso em testes, acessibilidade e casos de uso semelhantes.

Amostra

PointerInputModifierNode

Um Modifier.Node que recebe PointerInputChanges.

Amostra

ParentDataModifierNode

Um Modifier.Node que fornece dados para o layout pai.

Amostra

LayoutAwareModifierNode

Um Modifier.Node que recebe callbacks onMeasured e onPlaced.

Amostra

GlobalPositionAwareModifierNode

Um Modifier.Node que recebe um callback onGloballyPositioned com o LayoutCoordinates final do layout quando a posição global do conteúdo pode ter mudado.

Amostra

ObserverModifierNode

Modifier.Nodes que implementam ObserverNode podem fornecer a própria implementação de onObservedReadsChanged, que será chamada em resposta a mudanças nos objetos de snapshot lidos em um bloco observeReads.

Amostra

DelegatingNode

Um Modifier.Node que pode delegar trabalho a outras instâncias de Modifier.Node.

Isso pode ser útil para compor várias implementações de nó em uma.

Amostra

TraversableNode

Permite que as classes Modifier.Node naveguem para cima/para baixo na árvore de nós para classes do mesmo tipo ou para uma chave específica.

Amostra

Os nós são invalidados automaticamente quando a chamada de update é feita no elemento correspondente. Como nosso exemplo é um DrawModifierNode, sempre que a atualização é chamada no elemento, o nó aciona uma nova renderização e a cor é atualizada corretamente. É possível desativar a invalidação automática, conforme detalhado abaixo.

ModifierNodeElement

Um ModifierNodeElement é uma classe imutável que armazena os dados para criar ou atualizar o modificador personalizado:

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

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

As implementações de ModifierNodeElement precisam substituir os seguintes métodos:

  1. create: é a função que instancia o nó do modificador. Ele é chamado para criar o nó quando o modificador é aplicado pela primeira vez. Normalmente, isso significa construir o nó e configurá-lo com os parâmetros que foram transmitidos para a fábrica de modificadores.
  2. update: essa função é chamada sempre que esse modificador é fornecido no mesmo local em que o nó já existe, mas uma propriedade mudou. Isso é determinado pelo método equals da classe. O nó de modificador que foi criado anteriormente é enviado como um parâmetro para a chamada update. Nesse ponto, é necessário atualizar as propriedades dos nós para corresponder aos parâmetros atualizados. A capacidade de reutilização de nós dessa forma é fundamental para os ganhos de desempenho que o Modifier.Node traz. Portanto, é necessário atualizar o nó atual em vez de criar um novo no método update. No nosso exemplo de círculo, a cor do nó é atualizada.

Além disso, as implementações de ModifierNodeElement também precisam implementar equals e hashCode. O update só será chamado se uma comparação de igualdade com o elemento anterior retornar falso.

O exemplo acima usa uma classe de dados para fazer isso. Esses métodos são usados para verificar se um nó precisa ser atualizado ou não. Se o elemento tiver propriedades que não contribuem para determinar se um nó precisa ser atualizado ou se você quiser evitar classes de dados por motivos de compatibilidade binária, implemente manualmente equals e hashCode, por exemplo, o elemento modificador de padding.

Fábrica de modificadores

Essa é a plataforma de API pública do modificador. A maioria das implementações simplesmente cria o elemento modificador e o adiciona à cadeia de modificadores:

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

Exemplo completo

Essas três partes se unem para criar o modificador personalizado para desenhar um círculo usando as APIs 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)
    }
}

Situações comuns que usam Modifier.Node

Ao criar modificadores personalizados com Modifier.Node, confira algumas situações comuns que você pode encontrar.

Zero parâmetros

Se o modificador não tiver parâmetros, ele nunca precisará ser atualizado e, além disso, não precisará ser uma classe de dados. Confira um exemplo de implementação de um modificador que aplica um valor fixo de padding a um elemento combinável:

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

Como referenciar locais de composição

Os modificadores Modifier.Node não observam automaticamente as mudanças nos objetos de estado do Compose, como CompositionLocal. A vantagem dos modificadores Modifier.Node em relação aos que são criados com uma fábrica combinável é que eles podem ler o valor do local de composição de onde o modificador é usado na árvore da interface, não onde ele é alocado, usando currentValueOf.

No entanto, as instâncias de nó modificador não observam automaticamente as mudanças de estado. Para reagir automaticamente a uma mudança local de composição, leia o valor atual dentro de um escopo:

Este exemplo observa o valor de LocalContentColor para desenhar um plano de fundo com base na cor. Como ContentDrawScope observa as mudanças de snapshot, ele é redesenhado automaticamente quando o valor de LocalContentColor muda:

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

Para reagir a mudanças de estado fora de um escopo e atualizar automaticamente o modificador, use um ObserverModifierNode.

Por exemplo, Modifier.scrollable usa essa técnica para observar mudanças em LocalDensity. Confira um exemplo simplificado abaixo:

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

Modificador de animação

As implementações de Modifier.Node têm acesso a um coroutineScope. Isso permite o uso das APIs animatráveis do Compose. Por exemplo, este snippet modifica o CircleNode acima para aparecer e desaparecer repetidamente:

class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
    private val alpha = Animatable(1f)

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

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

Como compartilhar o estado entre modificadores usando a delegação

Os modificadores Modifier.Node podem delegar a outros nós. Há muitos casos de uso para isso, como extrair implementações comuns em diferentes modificadores, mas também pode ser usado para compartilhar o estado comum entre modificadores.

Por exemplo, uma implementação básica de um nó de modificador clicável que compartilha dados de interação:

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

Como desativar a invalidação automática de nós

Os nós Modifier.Node são invalidados automaticamente quando as chamadas ModifierNodeElement correspondentes são atualizadas. Em um modificador mais complexo, às vezes, é possível desativar esse comportamento para ter um controle mais refinado sobre quando o modificador invalida as fases.

Isso pode ser especialmente útil se o modificador personalizado modificar o layout e a renderização. A desativação da invalidação automática permite que você apenas invalide o draw quando somente as propriedades relacionadas ao draw, como color, mudarem, e não invalidem o layout. Isso pode melhorar a performance do modificador.

Um exemplo hipotético disso é mostrado abaixo com um modificador que tem uma lambda color, size e onClick como propriedades. Esse modificador invalida apenas o que é necessário e ignora qualquer invalidação que não seja:

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