Cómo crear modificadores personalizados

Compose proporciona muchos modificadores para comportamientos comunes de inmediato, pero también puedes crear tus propios modificadores personalizados.

Los modificadores tienen varias partes:

  • Una fábrica de modificadores
    • Esta es una función de extensión en Modifier, que proporciona una API idiomática para tu modificador y permite que los modificadores se encadenen fácilmente. La fábrica de modificadores produce los elementos modificadores que usa Compose para modificar tu IU.
  • Un elemento modificador
    • Aquí es donde puedes implementar el comportamiento de tu modificador.

Existen varias formas de implementar un modificador personalizado según la funcionalidad necesaria. A menudo, la forma más sencilla de implementar un modificador personalizado es implementar una fábrica de modificadores personalizada que combine otras fábricas de modificadores ya definidas. Si necesitas un comportamiento más personalizado, implementa el elemento modificador con las APIs de Modifier.Node, que son de nivel inferior, pero proporcionan más flexibilidad.

Encadena modificadores existentes

A menudo, es posible crear modificadores personalizados solo con los modificadores existentes. Por ejemplo, Modifier.clip() se implementa con el modificador graphicsLayer. Esta estrategia usa elementos modificadores existentes, y tú proporcionas tu propia fábrica de modificadores personalizada.

Antes de implementar tu propio modificador personalizado, verifica si puedes usar la misma estrategia.

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

O, si descubres que repites el mismo grupo de modificadores con frecuencia, puedes incluirlos en tu propio modificador:

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

Crea un modificador personalizado con una fábrica de modificadores componible

También puedes crear un modificador personalizado con una función de componibilidad para pasar valores a un modificador existente. Esto se conoce como fábrica de modificadores componibles.

Usar una fábrica de modificadores componibles para crear un modificador también permite usar APIs de Compose de nivel superior, como animate*AsState y otras APIs de animación respaldadas por el estado de Compose. Por ejemplo, el siguiente fragmento muestra un modificador que anima un cambio alfa cuando se habilita o inhabilita:

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

Si tu modificador personalizado es un método de conveniencia para proporcionar valores predeterminados desde un CompositionLocal, la forma más sencilla de implementarlo es usar una fábrica de modificadores componibles:

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

Este enfoque tiene algunas advertencias que se detallan a continuación.

Los valores de CompositionLocal se resuelven en el sitio de llamada de la fábrica de modificadores.

Cuando se crea un modificador personalizado con una fábrica de modificadores componibles, las variables locales de composición toman el valor del árbol de composición en el que se crean, no en el que se usan. Esto puede generar resultados inesperados. Por ejemplo, toma el ejemplo de modificador local de composición anterior, implementado de forma ligeramente diferente con una función de componibilidad:

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

Si no es así como esperas que funcione tu modificador, usa un Modifier.Node personalizado, ya que los Composition Locals se resolverán correctamente en el sitio de uso y se podrán elevar de forma segura.

Nunca se omiten los modificadores de funciones de componibilidad

Los modificadores de fábrica componibles nunca se omiten porque las funciones componibles que tienen valores de retorno no se pueden omitir. Esto significa que se llamará a tu función de modificador en cada recomposición, lo que puede ser costoso si se recompone con frecuencia.

Los modificadores de funciones de componibilidad se deben llamar dentro de una función de componibilidad.

Al igual que todas las funciones de componibilidad, un modificador de fábrica de componibilidad se debe llamar desde la composición. Esto limita el lugar al que se puede elevar un modificador, ya que nunca se puede elevar fuera de la composición. En comparación, las fábricas de modificadores que no son de componibilidad se pueden extraer de las funciones de componibilidad para permitir una reutilización más sencilla y mejorar el rendimiento:

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
}

Implementa un comportamiento de modificador personalizado con Modifier.Node

Modifier.Node es una API de nivel inferior para crear modificadores en Compose. Es la misma API en la que Compose implementa sus propios modificadores y es la forma más eficiente de crear modificadores personalizados.

Implementa un modificador personalizado con Modifier.Node

Hay tres partes para implementar un modificador personalizado con Modifier.Node:

  • Implementación de Modifier.Node que contiene la lógica y el estado de tu modificador.
  • Un ModifierNodeElement que crea y actualiza instancias de nodos modificadores.
  • Es un modificador opcional, como se detalló anteriormente.

Las clases ModifierNodeElement no tienen estado y se asignan instancias nuevas en cada recomposición, mientras que las clases Modifier.Node pueden tener estado y persistirán en varias recomposiciones, y hasta se pueden reutilizar.

En la siguiente sección, se describe cada parte y se muestra un ejemplo de cómo compilar un modificador personalizado para dibujar un círculo.

Modifier.Node

La implementación de Modifier.Node (en este ejemplo, CircleNode) implementa la funcionalidad de tu modificador personalizado.

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

En este ejemplo, se dibuja el círculo con el color que se pasó a la función del modificador.

Un nodo implementa Modifier.Node y cero o más tipos de nodos. Existen diferentes tipos de nodos según la funcionalidad que requiera tu modificador. El ejemplo anterior debe poder dibujar, por lo que implementa DrawModifierNode, lo que le permite anular el método de dibujo.

Los tipos disponibles son los siguientes:

Nodo

Uso

Vínculo de ejemplo

LayoutModifierNode

Un elemento Modifier.Node que cambia la forma en que se mide y se muestra su contenido unido.

Muestra

DrawModifierNode

Un Modifier.Node que se dibuja en el espacio del diseño.

Muestra

CompositionLocalConsumerModifierNode

Implementar esta interfaz permite que tu Modifier.Node lea los locales de composición.

Muestra

SemanticsModifierNode

Es un Modifier.Node que agrega pares clave-valor semánticos para usarlos en pruebas, accesibilidad y casos de uso similares.

Muestra

PointerInputModifierNode

Un Modifier.Node que recibe PointerInputChanges.

Muestra

ParentDataModifierNode

Un Modifier.Node que proporciona datos al diseño principal.

Muestra

LayoutAwareModifierNode

Un Modifier.Node que recibe devoluciones de llamada onMeasured y onPlaced.

Muestra

GlobalPositionAwareModifierNode

Un Modifier.Node que recibe una devolución de llamada de onGloballyPositioned con el LayoutCoordinates final del diseño cuando la posición global del contenido puede haber cambiado.

Muestra

ObserverModifierNode

Los Modifier.Node que implementan ObserverNode pueden proporcionar su propia implementación de onObservedReadsChanged, que se llamará en respuesta a los cambios en los objetos de instantánea que se leen dentro de un bloque observeReads.

Muestra

DelegatingNode

Un Modifier.Node que puede delegar trabajo a otras instancias de Modifier.Node

Esto puede ser útil para componer varias implementaciones de nodos en una sola.

Muestra

TraversableNode

Permite que las clases Modifier.Node recorran el árbol de nodos hacia arriba o hacia abajo para las clases del mismo tipo o para una clave en particular.

Muestra

Los nodos se invalidan automáticamente cuando se llama a la actualización en su elemento correspondiente. Dado que nuestro ejemplo es un DrawModifierNode, cada vez que se llama a la actualización en el elemento, el nodo activa un nuevo dibujo y su color se actualiza correctamente. Es posible inhabilitar la invalidación automática como se detalla a continuación.

ModifierNodeElement

Un ModifierNodeElement es una clase inmutable que contiene los datos para crear o actualizar tu 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
    }
}

Las implementaciones de ModifierNodeElement deben anular los siguientes métodos:

  1. create: Esta es la función que crea una instancia de tu nodo de modificador. Se llama a este método para crear el nodo cuando se aplica el modificador por primera vez. Por lo general, esto implica construir el nodo y configurarlo con los parámetros que se pasaron a la fábrica de modificadores.
  2. update: Se llama a esta función cada vez que se proporciona este modificador en el mismo lugar en el que ya existe este nodo, pero cambió una propiedad. Esto se determina con el método equals de la clase. El nodo del modificador que se creó anteriormente se envía como parámetro a la llamada de update. En este punto, debes actualizar las propiedades de los nodos para que correspondan con los parámetros actualizados. La capacidad de reutilizar los nodos de esta manera es clave para las mejoras de rendimiento que aporta Modifier.Node. Por lo tanto, debes actualizar el nodo existente en lugar de crear uno nuevo en el método update. En nuestro ejemplo del círculo, se actualiza el color del nodo.

Además, las implementaciones de ModifierNodeElement también deben implementar equals y hashCode. Solo se llamará a update si una comparación de igualdad con el elemento anterior devuelve falso.

En el ejemplo anterior, se usa una clase de datos para lograr esto. Estos métodos se usan para verificar si un nodo necesita actualizarse o no. Si tu elemento tiene propiedades que no contribuyen a determinar si se debe actualizar un nodo, o si deseas evitar las clases de datos por motivos de compatibilidad binaria, puedes implementar equals y hashCode de forma manual, p.ej., el elemento modificador de padding.

Fábrica de modificadores

Esta es la superficie de la API pública de tu modificador. La mayoría de las implementaciones simplemente crean el elemento modificador y lo agregan a la cadena de modificadores:

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

Ejemplo completo

Estas tres partes se unen para crear el modificador personalizado que dibuja un círculo con las APIs de 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)
    }
}

Situaciones habituales en las que se usa Modifier.Node

Cuando crees modificadores personalizados con Modifier.Node, estas son algunas situaciones comunes que podrías encontrar.

Sin parámetros

Si tu modificador no tiene parámetros, nunca necesita actualizarse y, además, no necesita ser una clase de datos. A continuación, se muestra una implementación de ejemplo de un modificador que aplica una cantidad fija de padding a un elemento componible:

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

Cómo hacer referencia a elementos locales de composición

Los modificadores Modifier.Node no observan automáticamente los cambios en los objetos de estado de Compose, como CompositionLocal. La ventaja que tienen los modificadores Modifier.Node sobre los modificadores que solo se crean con una fábrica de elementos componibles es que pueden leer el valor de los datos locales de composición desde donde se usa el modificador en tu árbol de IU, no desde donde se asigna el modificador, con currentValueOf.

Sin embargo, las instancias de nodos modificadores no observan automáticamente los cambios de estado. Para reaccionar automáticamente a un cambio local de composición, puedes leer su valor actual dentro de un alcance:

En este ejemplo, se observa el valor de LocalContentColor para dibujar un fondo según su color. Como ContentDrawScope observa los cambios de instantáneas, se vuelve a dibujar automáticamente cuando cambia el valor de LocalContentColor:

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

Para reaccionar a los cambios de estado fuera de un alcance y actualizar automáticamente tu modificador, usa un ObserverModifierNode.

Por ejemplo, Modifier.scrollable usa esta técnica para observar los cambios en LocalDensity. A continuación, se muestra un ejemplo simplificado:

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 animación

Las implementaciones de Modifier.Node tienen acceso a un coroutineScope. Esto permite el uso de las APIs de Compose Animatable. Por ejemplo, este fragmento modifica el CircleNode anterior para que aparezca y desaparezca de forma gradual repetidamente:

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

Cómo compartir el estado entre modificadores con delegación

Los modificadores Modifier.Node pueden delegar en otros nodos. Esto tiene muchos casos de uso, como extraer implementaciones comunes en diferentes modificadores, pero también se puede usar para compartir un estado común entre modificadores.

Por ejemplo, una implementación básica de un nodo modificador en el que se puede hacer clic y que comparte datos de interacción:

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

Cómo inhabilitar la invalidación automática de nodos

Los nodos Modifier.Node se invalidan automáticamente cuando se actualizan sus llamadas ModifierNodeElement correspondientes. A veces, en un modificador más complejo, es posible que desees inhabilitar este comportamiento para tener un control más detallado sobre cuándo tu modificador invalida fases.

Esto puede ser particularmente útil si tu modificador personalizado modifica tanto el diseño como el dibujo. Si inhabilitas la invalidación automática, solo se invalidará el dibujo cuando cambien las propiedades relacionadas con el dibujo, como color, y no se invalidará el diseño. Esto puede mejorar el rendimiento de tu modificador.

A continuación, se muestra un ejemplo hipotético de esto con un modificador que tiene lambdas color, size y onClick como propiedades. Este modificador solo invalida lo que es necesario y omite cualquier invalidación que no lo sea:

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