Données à champ d'application local avec CompositionLocal

CompositionLocal est un outil permettant de transmettre des données implicitement via la composition. Sur cette page, vous allez découvrir CompositionLocal de manière plus détaillée, apprendre à créer votre propre CompositionLocal et déterminer si CompositionLocal est une solution adaptée à votre cas d'utilisation.

Découvrez CompositionLocal

Généralement dans Compose, les données descendent dans l'arborescence de l'interface utilisateur en tant que paramètres pour chaque fonction modulable. Les dépendances d'un composable deviennent donc explicites. Cela peut toutefois s'avérer fastidieux pour les données très fréquemment et couramment utilisées, telles que les couleurs ou les styles de type. Consultez l'exemple suivant :

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = colors()
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = colors.onPrimary // ← need to access colors here
    )
}

Pour vous éviter de devoir transmettre les couleurs en tant que dépendance de paramètre explicite à la plupart des composables, Compose propose CompositionLocal. Cela vous permet de créer des objets nommés limités à l'arborescence qui peuvent être utilisés comme un moyen implicite pour faire circuler des données dans l'arborescence de l'interface utilisateur.

Les éléments CompositionLocal sont généralement accompagnés d'une valeur dans un nœud spécifique de l'arborescence de l'interface utilisateur. Cette valeur peut être utilisée par ses descendants composables sans déclarer le CompositionLocal en tant que paramètre dans la fonction modulable.

Le thème Material utilise CompositionLocal en arrière-plan. MaterialTheme est un objet qui fournit trois instances CompositionLocal (couleurs, typographie et formes) vous permettant de les récupérer ultérieurement dans n'importe quelle partie descendante de la composition. Plus précisément, il s'agit des propriétés LocalColors, LocalShapes et LocalTypography auxquelles vous pouvez accéder via les attributs colors, shapes et typography de MaterialTheme.

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colors, typography, and shapes are available
        // in MaterialTheme's content lambda.

        // ... content here ...
    }
}

// Some composable deep in the hierarchy of MaterialTheme
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        // `primary` is obtained from MaterialTheme's
        // LocalColors CompositionLocal
        color = MaterialTheme.colors.primary
    )
}

Le champ d'application d'une instance de CompositionLocal est limité à une partie de la composition afin que vous puissiez fournir différentes valeurs à différents niveaux de l'arborescence. La valeur current d'un CompositionLocal correspond à la valeur la plus proche fournie par un ancêtre dans cette partie de la composition.

Pour fournir une nouvelle valeur à un CompositionLocal, utilisez le CompositionLocalProvider et sa fonction infixe provides, qui associe une clé CompositionLocal à un value. Le lambda content de CompositionLocalProvider obtient la valeur fournie lors de l'accès à la propriété current de CompositionLocal. Lorsqu'une nouvelle valeur est fournie, Compose recompose les parties de la composition qui lisent le CompositionLocal.

À titre d'exemple, le LocalContentAlpha CompositionLocal contient la valeur alpha du contenu choisi utilisée pour le texte et l'iconographie afin de mettre en valeur ou masquer subtilement les différentes parties de l'interface utilisateur. Dans l'exemple suivant, CompositionLocalProvider est utilisé pour fournir différentes valeurs pour différentes parties de la composition.

@Composable
fun CompositionLocalExample() {
    MaterialTheme { // MaterialTheme sets ContentAlpha.high as default
        Column {
            Text("Uses MaterialTheme's provided alpha")
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("Medium value provided for LocalContentAlpha")
                Text("This Text also uses the medium value")
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
                    DescendantExample()
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the disabled alpha now")
}

Image 1. Aperçu du composable CompositionLocalExample.

Dans tous les exemples ci-dessus, les instances de CompositionLocal ont été utilisées en interne par les composables Material. Pour accéder à la valeur actuelle d'un CompositionLocal, utilisez sa propriété current. Dans l'exemple suivant, la valeur Context actuelle du LocalContext CompositionLocal généralement utilisée dans les applications Android est utilisée pour la mise en forme du texte :

@Composable
fun FruitText(fruitSize: Int) {
    // Get `resources` from the current value of LocalContext
    val resources = LocalContext.current.resources
    val fruitText = remember(resources, fruitSize) {
        resources.getQuantityString(R.plurals.fruit_title, fruitSize)
    }
    Text(text = fruitText)
}

Créer votre propre CompositionLocal

CompositionLocal est un outil permettant de transmettre des données implicitement via la composition.

Un autre indicateur clé pour l'utilisation de CompositionLocal est le cas où le paramètre est transversal et où les couches intermédiaires de la mise en œuvre ne doivent pas être informées de son existence. En effet, informer ces couches intermédiaires limiterait l'utilité du composable. Par exemple, l'interrogation des autorisations Android est accordée par un CompositionLocal en arrière-plan. Un composable de sélecteur de fichiers multimédias peut ajouter une nouvelle fonctionnalité pour accéder au contenu protégé par des autorisations sur l'appareil, sans modifier son API ni exiger que les appelants du sélecteur de contenus soient informés de ce contexte supplémentaire utilisé dans l'environnement.

Cependant, CompositionLocal n'est pas toujours la meilleure solution. Nous déconseillons l'utilisation excessive de CompositionLocal, car cela présente quelques inconvénients :

CompositionLocal rend le comportement d'un composable plus difficile à comprendre. Lorsqu'ils créent des dépendances implicites, les appelants de composables qui les utilisent doivent s'assurer qu'une valeur est respectée pour chaque CompositionLocal.

De plus, il est possible qu'il n'y ait pas de référence claire pour cette dépendance, car elle peut être mutée dans n'importe quelle partie de la composition. Ainsi, déboguer l'application en cas de problème peut être plus difficile, car vous devez accéder à la composition pour voir où la valeur current a été fournie. Des outils tels que Trouver des utilisations dans l'IDE ou Outil d'inspection de la mise en page de Compose fournissent suffisamment d'informations pour atténuer ce problème.

Décider d'utiliser ou non CompositionLocal

CompositionLocal peut être une bonne solution pour votre cas d'utilisation dans certaines conditions :

Un CompositionLocal doit disposer d'une bonne valeur par défaut. En l'absence de valeur par défaut, vous devez garantir qu'il est très difficile pour un développeur de se retrouver dans une situation où aucune valeur n'est fournie pour CompositionLocal. Si vous ne fournissez pas de valeur par défaut, vous risquez de rencontrer des problèmes lors de la création de tests. De même, la prévisualisation d'un composable qui utilise ce CompositionLocal exigera toujours qu'il soit explicitement fourni.

Évitez CompositionLocal pour les concepts qui ne sont pas considérés comme limités à l'arborescence ou aux sous-hiérarchies. Un CompositionLocal convient lorsqu'il peut être utilisé par n'importe quel descendant et non seulement par quelques-uns d'entre eux.

Si votre cas d'utilisation ne répond pas à ces exigences, consultez la section Alternatives à prendre en compte avant de créer un CompositionLocal.

Une pratique déconseillée consiste par exemple à créer un CompositionLocal qui contient le ViewModel d'un écran particulier, afin que tous les composables de cet écran puissent obtenir une référence au ViewModel pour exécuter une logique. Cette pratique est déconseillée, car tous les composables sous une arborescence d'interface utilisateur particulière n'ont pas besoin d'être informés de l'existence d'un ViewModel. Il est recommandé de ne transmettre aux composables que les informations dont ils ont besoin en suivant le schéma : les états descendent et les événements remontent. Cette approche facilitera la réutilisation et les tests de vos composables.

Créer un CompositionLocal

Deux API permettent de créer un CompositionLocal :

  • compositionLocalOf : la modification de la valeur fournie lors de la recomposition invalide uniquement le contenu qui lit sa valeur current.

  • staticCompositionLocalOf : contrairement à compositionLocalOf, les lectures d'un staticCompositionLocalOf ne sont pas suivies par Compose. La modification de la valeur entraîne la recomposition de l'intégralité du lambda content contenant le CompositionLocal au lieu des emplacements où la valeur current est lue dans la composition.

Si la valeur fournie au CompositionLocal est peu susceptible de changer ou ne changera jamais, utilisez staticCompositionLocalOf pour obtenir des avantages en termes de performances.

Par exemple, il est possible que le système de conception d'une application soit défini en fonction de l'élévation des composables à l'aide d'une ombre pour le composant d'interface utilisateur. Étant donné que les différentes élévations de l'application doivent se propager dans l'arborescence de l'interface utilisateur, nous utilisons un CompositionLocal. Comme la valeur de CompositionLocal est dérivée de manière conditionnelle en fonction du thème du système, nous utilisons l'API compositionLocalOf :

// LocalElevations.kt file

data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// Define a CompositionLocal global object with a default
// This instance can be accessed by all composables in the app
val LocalElevations = compositionLocalOf { Elevations() }

Fournir des valeurs à une CompositionLocal

Le composable CompositionLocalProvider associe des valeurs aux instances de CompositionLocal pour la hiérarchie donnée. Pour fournir une nouvelle valeur à un CompositionLocal, utilisez la fonction infixe provides qui associe une clé CompositionLocal à un value comme suit :

// MyActivity.kt file

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Calculate elevations based on the system theme
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // Bind elevation as the value for LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // This part of Composition will see the `elevations` instance
                // when accessing LocalElevations.current
            }
        }
    }
}

Consommer le CompositionLocal

CompositionLocal.current renvoie la valeur fournie par le CompositionLocalProvider le plus proche qui fournit une valeur à ce CompositionLocal :

@Composable
fun SomeComposable() {
    // Access the globally defined LocalElevations variable to get the
    // current Elevations in this part of the Composition
    Card(elevation = LocalElevations.current.card) {
        // Content
    }
}

Alternatives à prendre en compte

Un CompositionLocal peut être une solution excessive dans certains cas d'utilisation. Si votre cas d'utilisation ne répond pas aux critères spécifiés dans la section Décider d'utiliser CompositionLocal, une autre solution est peut-être plus adaptée.

Transmettre des paramètres explicites

Indiquer explicitement les dépendances du composable est une bonne habitude à prendre. Nous vous recommandons de transmettre aux composables uniquement ce dont ils ont besoin. Pour encourager la dissociation et la réutilisation des composables, chaque composable doit contenir le moins d'informations possible.

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel.data)
}

// Don't pass the whole object! Just what the descendant needs.
// Also, don't  pass the ViewModel as an implicit dependency using
// a CompositionLocal.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { /* ... */ }

// Pass only what the descendant needs
@Composable
fun MyDescendant(data: DataToDisplay) {
    // Display data
}

Inversion du contrôle

Un autre moyen d'éviter de transmettre des dépendances inutiles à un composable consiste à utiliser l'inversion du contrôle. L'exécution d'une logique à l'aide d'une dépendance est assurée par le parent au lieu du descendant.

Consultez l'exemple suivant, dans lequel un descendant doit déclencher la requête pour charger des données :

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel)
}

@Composable
fun MyDescendant(myViewModel: MyViewModel) {
    Button(onClick = { myViewModel.loadData() }) {
        Text("Load data")
    }
}

Selon le cas, MyDescendant peut avoir beaucoup de responsabilités. De plus, la transmission de MyViewModel en tant que dépendance rend MyDescendant moins réutilisable, car ils sont désormais associés. Prenons l'alternative qui ne transmet pas la dépendance au descendant et qui utilise les principes d'inversion du contrôle rendant l'ancêtre responsable de l'exécution de la logique :

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusableLoadDataButton(
        onLoadClick = {
            myViewModel.loadData()
        }
    )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
    Button(onClick = onLoadClick) {
        Text("Load data")
    }
}

Cette approche peut être plus adaptée à certains cas d'utilisation, car elle dissocie l'enfant de ses ancêtres immédiats. Les composables ancêtres ont tendance à devenir plus complexes au profit de composables de niveau inférieur plus flexibles.

De même, les lambdas de contenu @Composable peuvent être utilisés de la même manière pour obtenir les mêmes avantages :

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusablePartOfTheScreen(
        content = {
            Button(
                onClick = {
                    myViewModel.loadData()
                }
            ) {
                Text("Confirm")
            }
        }
    )
}

@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
    Column {
        // ...
        content()
    }
}