Créer des modificateurs personnalisés

Compose fournit directement de nombreux modificateurs pour les comportements courants, mais vous pouvez également créer vos propres modificateurs personnalisés.

Les modificateurs se composent de plusieurs parties:

  • Une fabrique de modificateurs
    • Il s'agit d'une fonction d'extension sur Modifier, qui fournit une API idiomatique pour votre modificateur et permet d'enchaîner facilement les modificateurs. La fabrique de modificateurs produit les éléments de modificateur utilisés par Compose pour modifier votre UI.
  • Un élément modificateur
    • C'est ici que vous pouvez implémenter le comportement de votre modificateur.

Il existe plusieurs façons d'implémenter un modificateur personnalisé selon la fonctionnalité requise. Souvent, le moyen le plus simple d'implémenter un modificateur personnalisé consiste à implémenter une fabrique de modificateurs personnalisée qui combine d'autres fabriques de modificateurs déjà définies. Si vous avez besoin d'un comportement plus personnalisé, implémentez l'élément de modificateur à l'aide des API Modifier.Node, qui sont de niveau inférieur, mais offrent plus de flexibilité.

Enchaîner des modificateurs existants

Il est souvent possible de créer des modificateurs personnalisés en utilisant simplement des modificateurs existants. Par exemple, Modifier.clip() est implémenté à l'aide du modificateur graphicsLayer. Cette stratégie utilise des éléments de modificateur existants et vous fournissez votre propre fabrique de modificateurs personnalisée.

Avant d'implémenter votre propre modificateur personnalisé, voyez si vous pouvez utiliser la même stratégie.

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

Si vous constatez que vous répétez souvent le même groupe de modificateurs, vous pouvez les encapsuler dans votre propre modificateur:

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

Créer un modificateur personnalisé à l'aide d'une fabrique de modificateurs composables

Vous pouvez également créer un modificateur personnalisé à l'aide d'une fonction composable pour transmettre des valeurs à un modificateur existant. C'est ce qu'on appelle une fabrique de modificateurs composables.

L'utilisation d'une fabrique de modificateurs de composables pour créer un modificateur permet également d'utiliser des API Compose de niveau supérieur, telles que animate*AsState et d'autres API d'animation reposant sur l'état de Compose. Par exemple, l'extrait suivant montre un modificateur qui anime une modification alpha lorsqu'il est activé ou désactivé:

@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 votre modificateur personnalisé est une méthode pratique permettant de fournir des valeurs par défaut à partir d'un CompositionLocal, le moyen le plus simple de l'implémenter consiste à utiliser une fabrique de modificateurs de composables:

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

Cette approche comporte certaines mises en garde détaillées ci-dessous.

Les valeurs CompositionLocal sont résolues au niveau du site d'appel de la fabrique de modificateurs

Lorsque vous créez un modificateur personnalisé à l'aide d'une fabrique de modificateurs composables, les fichiers locaux de la composition récupèrent la valeur de l'arborescence de composition où ils sont créés, et non utilisés. Cela peut entraîner des résultats inattendus. Prenons l'exemple du modificateur local de composition présenté ci-dessus, implémenté légèrement différemment à l'aide d'une fonction modulable:

@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 ce n'est pas ce que vous attendez de votre modificateur, utilisez plutôt un Modifier.Node personnalisé, car les compositions locales seront correctement résolues sur le site d'utilisation et pourront être hissées en toute sécurité.

Les modificateurs de fonctions composables ne sont jamais ignorés.

Les modificateurs de fabrique composables ne sont jamais ignorés, car les fonctions composables avec des valeurs renvoyées ne peuvent pas être ignorées. Cela signifie que votre fonction de modificateur sera appelée à chaque recomposition, ce qui peut être coûteux si elle se recompose fréquemment.

Les modificateurs de fonctions modulables doivent être appelés dans une fonction modulable

Comme toutes les fonctions modulables, un modificateur de fabrique composable doit être appelé à partir de la composition. Cela limite où un modificateur peut être hissé, car il ne peut jamais être hissé en dehors de la composition. En comparaison, les fabriques de modificateurs non modulables peuvent être exclues des fonctions modulables pour faciliter la réutilisation et améliorer les performances:

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
}

Implémenter le comportement du modificateur personnalisé à l'aide de Modifier.Node

Modifier.Node est une API de niveau inférieur permettant de créer des modificateurs dans Compose. Il s'agit de la même API dans laquelle Compose implémente ses propres modificateurs. Il s'agit du moyen le plus performant de créer des modificateurs personnalisés.

Implémenter un modificateur personnalisé à l'aide de Modifier.Node

L'implémentation d'un modificateur personnalisé à l'aide de Modifier.Node s'effectue en trois parties:

  • Une implémentation de Modifier.Node qui contient la logique et l'état de votre modificateur.
  • Un objet ModifierNodeElement qui crée et met à jour les instances de nœud de modificateur.
  • Une fabrique de modificateurs facultative, comme détaillé ci-dessus.

Les classes ModifierNodeElement sont sans état et de nouvelles instances sont allouées à chaque recomposition, tandis que les classes Modifier.Node peuvent être avec état et survivre lors de plusieurs recompositions, et peuvent même être réutilisées.

La section suivante décrit chaque partie et montre comment créer un modificateur personnalisé pour dessiner un cercle.

Modifier.Node

L'implémentation de Modifier.Node (dans cet exemple, CircleNode) met en œuvre la fonctionnalité de votre modificateur personnalisé.

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

Dans cet exemple, il dessine le cercle avec la couleur transmise à la fonction de modificateur.

Un nœud implémente Modifier.Node, ainsi qu'aucun ou plusieurs types de nœuds. Il existe différents types de nœuds en fonction des fonctionnalités requises par votre modificateur. L'exemple ci-dessus doit pouvoir dessiner. Il implémente donc DrawModifierNode, qui lui permet de remplacer la méthode de dessin.

Les types disponibles sont les suivants:

Nœud

Utilisation

Exemple de lien

LayoutModifierNode

Modifier.Node qui modifie la façon dont son contenu encapsulé est mesuré et mis en page.

Exemple

DrawModifierNode

Un élément Modifier.Node qui s'affiche dans l'espace de la mise en page.

Exemple

CompositionLocalConsumerModifierNode

L'implémentation de cette interface permet à votre Modifier.Node de lire les compositions locales.

Exemple

SemanticsModifierNode

Un Modifier.Node qui ajoute une clé-valeur sémantique à utiliser pour les tests, l'accessibilité et des cas d'utilisation similaires.

Exemple

PointerInputModifierNode

Un Modifier.Node qui reçoit PointerInputChanges.

Exemple

ParentDataModifierNode

Un élément Modifier.Node qui fournit des données à la mise en page parent.

Exemple

LayoutAwareModifierNode

Un Modifier.Node qui reçoit des rappels onMeasured et onPlaced.

Exemple

GlobalPositionAwareModifierNode

Un Modifier.Node qui reçoit un rappel onGloballyPositioned avec le LayoutCoordinates final de la mise en page lorsque la position globale du contenu a pu changer.

Exemple

ObserverModifierNode

Les Modifier.Node qui implémentent ObserverNode peuvent fournir leur propre implémentation de onObservedReadsChanged, qui sera appelé en réponse aux modifications apportées aux objets instantanés lus dans un bloc observeReads.

Exemple

DelegatingNode

Un Modifier.Node capable de déléguer le travail à d'autres instances Modifier.Node.

Cela peut être utile pour regrouper plusieurs implémentations de nœuds en une seule.

Exemple

TraversableNode

Permet aux classes Modifier.Node de balayer l'arborescence de nœuds pour trouver des classes du même type ou pour une clé spécifique.

Exemple

Les nœuds sont automatiquement invalidés lorsqu'une mise à jour est appelée sur l'élément correspondant. Comme notre exemple est un DrawModifierNode, chaque fois que l'élément est mis à jour, le nœud déclenche un redessin et sa couleur se met à jour correctement. Il est possible de désactiver l'invalidation automatique, comme indiqué ci-dessous.

ModifierNodeElement

Un ModifierNodeElement est une classe immuable qui contient les données nécessaires à la création ou à la mise à jour de votre modificateur personnalisé:

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

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

Les implémentations de ModifierNodeElement doivent remplacer les méthodes suivantes:

  1. create: il s'agit de la fonction qui instancie votre nœud de modificateur. Cette méthode est appelée pour créer le nœud lorsque votre modificateur est appliqué pour la première fois. En général, cela revient à construire le nœud et à le configurer avec les paramètres transmis à la fabrique de modificateurs.
  2. update: cette fonction est appelée chaque fois que ce modificateur est fourni au même endroit que ce nœud existe déjà, mais qu'une propriété a été modifiée. Ce paramètre est déterminé par la méthode equals de la classe. Le nœud de modificateur créé précédemment est envoyé en tant que paramètre à l'appel update. À ce stade, vous devez mettre à jour les propriétés des nœuds pour qu'elles correspondent aux paramètres mis à jour. La possibilité de réutiliser les nœuds de cette manière est essentielle pour les gains de performances apportés par Modifier.Node. Par conséquent, vous devez mettre à jour le nœud existant au lieu d'en créer un autre dans la méthode update. Dans notre exemple de cercle, la couleur du nœud est mise à jour.

De plus, les implémentations ModifierNodeElement doivent également implémenter equals et hashCode. update n'est appelé que si une comparaison égale à l'élément précédent renvoie la valeur "false".

L'exemple ci-dessus utilise une classe de données pour y parvenir. Ces méthodes permettent de vérifier si un nœud doit être mis à jour ou non. Si votre élément comporte des propriétés qui ne contribuent pas à la mise à jour d'un nœud, ou si vous souhaitez éviter les classes de données pour des raisons de compatibilité binaire, vous pouvez implémenter manuellement equals et hashCode (par exemple, l'élément de modificateur de marge intérieure).

Fabrique de modificateurs

Il s'agit de la surface d'API publique de votre modificateur. La plupart des implémentations créent simplement l'élément de modificateur et l'ajoutent à la chaîne de modificateurs:

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

Exemple complet

Ces trois parties sont réunies pour créer le modificateur personnalisé permettant de dessiner un cercle à l'aide des 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)
    }
}

Cas courants avec Modifier.Node

Voici quelques situations courantes que vous pouvez rencontrer lorsque vous créez des modificateurs personnalisés avec Modifier.Node.

Aucun paramètre

Si votre modificateur ne comporte aucun paramètre, il n'a jamais besoin d'être mis à jour. De plus, il n'a pas besoin d'être une classe de données. Voici un exemple d'implémentation d'un modificateur qui applique une marge intérieure fixe à un composable:

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

Faire référence à la composition locale

Les modificateurs Modifier.Node n'observent pas automatiquement les modifications apportées aux objets d'état Compose, tels que CompositionLocal. L'avantage des modificateurs Modifier.Node par rapport aux modificateurs qui viennent d'être créés avec une fabrique modulable est qu'ils peuvent lire la valeur de la composition locale à partir de l'endroit où le modificateur est utilisé dans l'arborescence de l'interface utilisateur, et non à l'endroit où le modificateur est alloué, à l'aide de currentValueOf.

Toutefois, les instances de nœud de modificateur n'observent pas automatiquement les changements d'état. Pour réagir automatiquement à une modification locale d'une composition, vous pouvez lire sa valeur actuelle dans un champ d'application:

Cet exemple observe la valeur de LocalContentColor pour dessiner un arrière-plan en fonction de sa couleur. Comme ContentDrawScope observe les modifications de l'instantané, cette opération se redessine automatiquement lorsque la valeur de LocalContentColor change:

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

Pour réagir aux changements d'état en dehors d'un champ d'application et mettre automatiquement à jour votre modificateur, utilisez un ObserverModifierNode.

Par exemple, Modifier.scrollable utilise cette technique pour observer les modifications dans LocalDensity. Voici un exemple simplifié:

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

Modificateur d'animation

Les implémentations de Modifier.Node ont accès à un coroutineScope. Cela permet d'utiliser les API Compose Animatable. Par exemple, cet extrait modifie l'élément CircleNode ci-dessus pour apparaître et disparaître en fondu à plusieurs reprises:

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

Partage de l'état entre les modificateurs à l'aide de la délégation

Les modificateurs Modifier.Node peuvent déléguer à d'autres nœuds. Il existe de nombreux cas d'utilisation, tels que l'extraction des implémentations courantes de différents modificateurs, mais elle peut également être utilisée pour partager un état commun entre les modificateurs.

Par exemple, une implémentation de base d'un nœud de modificateur cliquable qui partage des données d'interaction:

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

Désactiver l'invalidation automatique des nœuds

Les nœuds Modifier.Node sont automatiquement invalidés lorsque leurs appels ModifierNodeElement correspondants sont mis à jour. Dans le cas d'un modificateur plus complexe, vous pouvez parfois désactiver ce comportement pour contrôler plus précisément à quel moment votre modificateur invalide des phases.

Cela peut être particulièrement utile si votre modificateur personnalisé modifie à la fois la mise en page et le dessin. La désactivation de l'invalidation automatique vous permet d'invalider simplement le dessin lorsque seules les propriétés liées à celui-ci, telles que color, changent, sans l'invalider. Cela peut améliorer les performances de votre modificateur.

Un exemple hypothétique de ceci est illustré ci-dessous avec un modificateur dont les propriétés sont les lambda color, size et onClick. Ce modificateur n'invalide que ce qui est requis et ignore toute invalidation qui n'est pas:

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