État avancé et effets secondaires dans Jetpack Compose

Restez organisé à l'aide des collections Enregistrez et classez les contenus selon vos préférences.

1. Introduction

Dans cet atelier de programmation, vous allez découvrir des concepts avancés liés aux API State (états) et Side Effect (effets secondaires) dans Jetpack Compose. Nous verrons comment créer un conteneur d'état pour des composables avec état à la logique compliquée, comment créer des coroutines et appeler des fonctions de suspension à partir du code Compose, et comment déclencher des effets secondaires pour accomplir différents cas d'utilisation.

Points abordés

Ce dont vous avez besoin

Objectifs de l'atelier

Dans cet atelier de programmation, nous commencerons avec une application inachevée, l'application Crane, à laquelle nous ajouterons des fonctionnalités pour l'améliorer.

b2c6b8989f4332bb.gif

2. Configuration

Obtenir le code

Le code de cet atelier de programmation est disponible dans le dépôt GitHub android-compose-codelabs. Pour le cloner, exécutez la commande suivante :

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

Vous pouvez également télécharger le dépôt sous forme de fichier ZIP :

Découvrir l'application exemple

Le dépôt que vous venez de télécharger contient du code pour tous les ateliers de programmation traitant de Compose. Pour cet atelier, ouvrez le projet AdvancedStateAndSideEffectsCodelab dans Android Studio Arctic Fox.

Nous vous recommandons de commencer par le code de la branche "main", puis de suivre l'atelier étape par étape, à votre propre rythme.

Au cours de cet atelier de programmation, vous découvrirez des extraits de code que vous devrez ajouter au projet. À certains endroits, vous devrez également supprimer le code qui est explicitement mentionné dans les commentaires sur les extraits de code.

Se familiariser avec le code et exécuter l'application exemple

Prenez quelques instants pour explorer la structure du projet et exécuter l'application.

162c42b19dafa701.png

Lorsque vous exécutez l'application à partir de la branche principale, vous verrez que certaines fonctionnalités telles que le panneau ou le chargement des destinations des vols ne fonctionnent pas. C'est ce sur quoi nous allons travailler dans les prochaines étapes de cet atelier de programmation.

b2c6b8989f4332bb.gif

Tests de l'interface utilisateur

L'application est couverte par des tests d'interface utilisateur très basiques disponibles dans le dossier androidTest, qui doivent toujours être transmis pour les branches main et end.

[Facultatif] Afficher la carte sur l'écran des détails

Il n'est pas du tout nécessaire d'afficher la carte de la ville sur l'écran des détails. Toutefois, si vous souhaitez la voir, vous devez obtenir une clé API personnelle, comme indiqué dans la documentation de Maps. Insérez cette clé dans le fichier local.properties comme suit :

// local.properties file
google.maps.key={insert_your_api_key_here}

Solution de l'atelier de programmation

Pour obtenir la branche end à l'aide de Git, exécutez la commande suivante :

$ git clone -b end https://github.com/googlecodelabs/android-compose-codelabs

Vous pouvez également télécharger le code de solution ici :

Questions fréquentes

3. Exécuter un flux à partir de ViewModel

Comme vous l'avez peut-être remarqué, lorsque vous exécutez l'application à partir de la branche main, la liste des destinations des vols est vide. Pour voir ce qui se passe, ouvrez le fichier home/CraneHome.kt et examinez le composable CraneHomeContent.

Un commentaire "TODO" au-dessus de la définition de suggestedDestinations est associé à une liste vide mémorisée. Voici ce qui s'affiche à l'écran : une liste vide ! Au cours de cette étape, nous allons corriger et afficher les destinations suggérées par MainViewModel.

66ae2543faaf2e91.png

Ouvrez home/MainViewModel.kt et examinez StateFlow suggestedDestinations initialisé sur destinationsRepository.destinations et mis à jour lorsque les fonctions updatePeople ou toDestinationChanged sont appelées.

Nous souhaitons que notre UI du composable CraneHomeContent soit mise à jour chaque fois qu'un nouvel élément est émis dans le flux de données suggestedDestinations. Nous pouvons utiliser la fonction StateFlow.collectAsState(). Lorsqu'utilisé dans une fonction modulable, collectAsState() collecte les valeurs de StateFlow et représente la dernière valeur via l'API State de Compose. Le code Compose qui lit cette valeur d'état se base alors sur ces nouvelles valeurs.

Revenez au composable CraneHomeContent et remplacez la ligne qui attribue suggestedDestinations par un appel à collectAsState sur la propriété suggestedDestinations de ViewModel :

import androidx.compose.runtime.collectAsState

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsState()
    // ...
}

Si vous exécutez l'application, vous verrez que la liste des destinations s'affiche et qu'elle change dès que vous modifiez le nombre de passagers.

d656748c7c583eb8.gif

4. LaunchedEffect et rememberUpdatedState

Le projet contient un fichier home/LandingScreen.kt qui n'est pas utilisé pour le moment. Nous voulons ajouter un écran de destination à l'application, qui pourrait potentiellement être utilisé pour charger toutes les données nécessaires en arrière-plan.

L'écran de destination occupe la totalité de l'écran et le logo de l'application s'affiche au milieu. Idéalement, nous voudrions afficher l'écran, et, une fois les données chargées, informer l'appelant que l'écran de destination peut être fermé à l'aide du rappel onTimeout.

Les coroutines Kotlin sont recommandées pour effectuer des opérations asynchrones dans Android. Une application utilise généralement des coroutines pour charger des éléments en arrière-plan au démarrage. Jetpack Compose propose des API qui permettent de sécuriser les coroutines dans la couche de l'UI. Comme cette application ne communique pas avec un backend, nous utiliserons la fonction delay des coroutines pour simuler le chargement d'éléments en arrière-plan.

Un effet secondaire dans Compose est un changement d'état de l'application qui se produit en dehors du champ d'application d'une fonction modulable. La modification de l'état pour afficher/masquer l'écran de destination se produit dans le rappel onTimeout et puisqu'avant l'appel à onTimeout nous devons charger des éléments à l'aide de coroutines, le changement d'état doit se produire dans le contexte d'une coroutine !

Pour appeler des fonctions de suspension en toute sécurité depuis un composable, utilisez l'API LaunchedEffect, qui déclenche un effet secondaire au niveau de la coroutine dans Compose.

Lorsque LaunchedEffect entre dans la composition, il lance une coroutine avec le bloc de code transmis comme paramètre. La coroutine sera annulée si LaunchedEffect quitte la composition.

Voyons pourquoi le code suivant est incorrect et comment utiliser cette API malgré tout. Nous appellerons le composable LandingScreen plus tard dans cette étape.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Certaines API d'effets secondaires telles que LaunchedEffect utilisent un nombre variable de clés comme paramètre pour redémarrer l'effet chaque fois que l'une de ces clés change. Avez-vous repéré l'erreur ? Nous ne voulons pas relancer l'effet si onTimeout change !

Pour déclencher l'effet secondaire une seule fois au cours du cycle de vie de ce composable, utilisez une constante comme clé, par exemple LaunchedEffect(true) { ... }. Cependant, la code n'est alors plus protégé des modifications apportées à onTimeout.

Si onTimeout change alors que l'effet secondaire est en cours, rien ne garantit que le dernier onTimeout sera appelé à la fin de l'effet. Pour le vérifier, capturez et mettez à jour la nouvelle valeur à l'aide de l'API rememberUpdatedState :

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Afficher l'écran de destination

Nous devons maintenant afficher l'écran de destination lorsque l'application est ouverte. Ouvrez le fichier home/MainActivity.kt et vérifiez que MainScreen est le premier composable à être appelé.

Dans le composable MainScreen, nous pouvons simplement ajouter un état interne qui vérifie si l'atterrissage doit être affiché ou non :

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

Si vous exécutez l'application maintenant, LandingScreen devrait apparaître et disparaître au bout de deux secondes.

e3fd932a5b95faa0.gif

5. rememberCoroutineScope

Au cours de cette étape, nous allons faire fonctionner le panneau de navigation. Pour le moment, rien ne se passe lorsque vous appuyez sur le menu hamburger.

Ouvrez le fichier home/CraneHome.kt et consultez le composable CraneHome pour voir où nous devons ouvrir le panneau de navigation. Dans le rappel openDrawer !

Dans CraneHome, nous avons un scaffoldState contenant un DrawerState. DrawerState propose des méthodes pour programmer l'ouverture et la fermeture du panneau de navigation. Toutefois, un message d'erreur s'affichera si vous tentez de saisir scaffoldState.drawerState.open() dans le rappel openDrawer. En effet, la fonction open est une fonction de suspension. Nous sommes de nouveau dans le domaine des coroutines.

Hormis les API pour sécuriser les appels des coroutines depuis la couche de l'interface utilisateur, certaines API Compose sont des fonctions de suspension. L'API permettant d'ouvrir le panneau de navigation en est un exemple. Les fonctions de suspension peuvent non seulement exécuter du code asynchrone, mais également représenter des concepts qui se produisent avec le temps. Comme l'ouverture du tiroir nécessite un certain temps, mouvement et des animations potentielles, cela se reflète parfaitement dans la fonction de suspension, qui suspend l'exécution de la coroutine où elle est appelée jusqu'à la fin de l'ouverture et la reprise de l'exécution.

scaffoldState.drawerState.open() doit être appelé dans une coroutine. Qu'est-ce qu'on peut faire d'autre ? openDrawer est une fonction de rappel simple, ainsi :

  • Nous ne pouvons pas simplement appeler des fonctions de suspension, car openDrawer n'est pas exécuté dans le contexte d'une coroutine.
  • Nous ne pouvons pas utiliser LaunchedEffect comme nous l'avions fait précédemment, car nous ne pouvons pas appeler de composables dans openDrawer. Nous ne sommes pas dans la composition.

Notre objectif est de pouvoir lancer une coroutine. Mais quel champ d'application utiliser ? Dans l'idéal, CoroutineScope doit suivre le cycle de vie de son site d'appel. Pour ce faire, utilisez l'API rememberCoroutineScope. Le champ d'application sera automatiquement annulé lorsqu'il quittera la composition. Avec ce champ d'application, vous pouvez démarrer des coroutines lorsque vous ne vous trouvez pas dans la composition, par exemple dans le rappel openDrawer.

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

Si vous exécutez l'application, vous verrez que le panneau de navigation s'ouvre lorsque vous appuyez sur l'icône du menu hamburger.

92957c04a35e91e3.gif

LaunchedEffect vs rememberCoroutineScope

Dans ce cas, utiliser LaunchedEffect était impossible, car nous devions déclencher l'appel pour créer une coroutine dans un rappel standard qui se trouve en dehors de la composition.

Si vous revenez à l'étape de l'écran de destination qui utilisait LaunchedEffect, pourriez-vous utiliser rememberCoroutineScope et appeler scope.launch { delay(); onTimeout(); } au lieu de LaunchedEffect ?

Vous auriez pu faire ça et le code aurait semblé fonctionner, mais ce serait incorrect. Comme expliqué dans la documentation Réfléchir dans Compose, les composables peuvent être appelés par Compose à tout moment. LaunchedEffect garantit que l'effet secondaire sera exécuté lorsque l'appel à ce composable parviendra à la composition. Si vous utilisez rememberCoroutineScope et scope.launch dans le corps de LandingScreen, la coroutine sera exécutée chaque fois que LandingScreen est appelé par Compose, que cet appel fasse ou non partie de la composition. Par conséquent, vous gaspillerez des ressources et vous n'exécuterez pas cet effet secondaire dans un environnement contrôlé.

6. Créer un conteneur d'état

Avez-vous remarqué que si vous appuyez sur Choose Destination (Sélectionner une destination), vous pouvez modifier le champ et filtrer les villes en fonction de votre recherche ? Vous avez sans doute également remarqué que le style du texte change chaque fois que vous modifiez la section Choose Destination.

dde9ef06ca4e5191.gif

Ouvrez le fichier base/EditableUserInput.kt. Le composable avec état CraneEditableUserInput utilise des paramètres tels que hint et caption, qui correspond au texte préinséré à côté de l'icône. Par exemple, la mention caption To (À) s'affiche lorsque vous saisissez une destination.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

Pourquoi ?

La logique permettant de mettre à jour l'élément textState et de déterminer si ce qui est affiché correspond au hint ou non, se trouve dans le corps du composable CraneEditableUserInput. Cela comporte quelques inconvénients :

  • La valeur de TextField n'est pas hissée et ne peut donc pas être contrôlée de l'extérieur, ce qui rend les tests plus difficiles.
  • La logique de ce composable pourrait devenir plus complexe et l'état interne pourrait être plus facilement désynchronisé.

En créant un conteneur d'état pour l'état interne de ce composable, vous pouvez centraliser toutes les modifications d'état au même endroit. Il est alors plus difficile pour l'état d'être désynchronisé, et la logique associée est regroupée dans une seule classe. De plus, cet état peut être facilement hissé et utilisé par les appelants de ce composable.

Dans ce cas, il est recommandé de hisser l'état, car il s'agit d'un composant d'interface utilisateur de bas niveau qui peut être réutilisé dans d'autres parties de l'application. Par conséquent, plus le composant est flexible et facile à contrôler, mieux c'est.

Créer le conteneur d'état

CraneEditableUserInput étant un composant réutilisable, nous allons créer une classe standard comment conteneur d'état intitulée EditableUserInputState dans le même fichier, qui se présente comme suit :

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint
}

La classe doit présenter les caractéristiques suivantes :

  • text est un état modifiable de type String, tout comme dans CraneEditableUserInput. Il est important d'utiliser mutableStateOf afin que Compose suive les modifications apportées à la valeur et se recompose lorsque des modifications se produisent.
  • text est une variable de type var, ce qui permet d'effectuer une mutation directe depuis l'extérieur de la classe.
  • La classe utilise un initialText comme dépendance pour initialiser text.
  • La logique pour savoir si text correspond au hint ou se trouve au niveau de la propriété isHint qui effectue le contrôle à la demande.

Si la logique se complexifie à l'avenir, il nous suffira de modifier une seule classe : EditableUserInputState.

Enregistrer le conteneur d'état

Les conteneurs d'état doivent être stockés pour les conserver dans la composition et ne pas avoir à en un créer une autre à chaque fois. Nous vous recommandons de créer une méthode dans le même fichier afin de supprimer le code récurrent et d'éviter toute erreur. Dans le fichier base/EditableUserInput.kt, ajoutez le code suivant :

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

Si nous n'enregistrons (remember) que cet état, il ne survivra pas à la recréation de l'activité. Pour ce faire, nous pouvons plutôt utiliser l'API rememberSaveable, qui se comporte de la même manière que remember, mais dont la valeur stockée survit également à la recréation de l'activité et du processus. En interne, l'API utilise le mécanisme enregistré de l'état de l'instance.

rememberSaveable effectue toutes ces opérations sans solliciter les objets, qui peuvent être stockés dans un Bundle. Ce n'est pas le cas pour la classe EditableUserInputState que nous avons créée dans notre projet. Nous devons donc indiquer à rememberSaveable comment enregistrer et restaurer une instance de cette classe à l'aide d'un Saver.

Créer un saver personnalisé

Un Saver décrit comment un objet peut être converti en quelque chose de Saveable (enregistrable). Les implémentations d'un Saver doivent forcer deux fonctions :

  • save pour convertir la valeur d'origine en valeur enregistrable.
  • restore pour convertir la valeur restaurée en instance de la classe d'origine.

Dans notre cas, au lieu de créer une implémentation personnalisée de Saver pour la classe EditableUserInputState, nous pouvons utiliser certaines des API Compose existantes, telles que listSaver ou mapSaver (qui stocke les valeurs à enregistrer dans une List ou une Map) afin d'avoir moins de code à écrire.

Nous vous recommandons de placer les définitions de Saver à proximité de la classe concernée. Étant donné qu'il doit être accessible de manière statique, ajoutons le Saver pour EditableUserInputState dans un companion object (objet compagnon). Dans le fichier base/EditableUserInput.kt, ajoutez l'implémentation de Saver :

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

Dans ce cas, nous utilisons un listSaver comme détail d'implémentation pour stocker et restaurer une instance de EditableUserInputState dans le saver.

Nous pouvons désormais utiliser ce saver dans rememberSaveable (au lieu de remember) dans la méthode rememberEditableUserInputState, créée précédemment :

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

Ainsi, l'état mémorisé EditableUserInput survivra à la recréation de processus et d'activités.

Utiliser le conteneur d'état

Nous allons utiliser EditableUserInputState au lieu de text et de isHint, mais nous ne voulons pas l'utiliser uniquement comme état interne dans CraneEditableUserInput, car il n'y a aucun moyen pour l'appelant de composable de contrôler l'état. À la place, nous souhaitons hisser EditableUserInputState afin que les appelants puissent contrôler l'état de CraneEditableUserInput. Si nous hissons l'état, le composable peut être utilisé dans les aperçus et testé plus facilement, car vous pouvez modifier son état depuis l'appelant.

Pour ce faire, nous devons modifier les paramètres de la fonction modulable et lui attribuer une valeur par défaut si nécessaire. Comme nous souhaitons peut-être autoriser CraneEditableUserInput avec des hints vides, ajoutez un argument par défaut :

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

Vous avez probablement remarqué que le paramètre onInputChanged a disparu ! Étant donné que l'état peut être hissé, si les appelants veulent savoir si l'entrée a changé, ils peuvent contrôler l'état et le transmettre à cette fonction.

Nous devons ensuite modifier le corps de la fonction pour qu'il utilise l'état hissé plutôt que l'état interne utilisé précédemment. Après refactorisation, la fonction devrait se présenter comme suit :

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.text = it },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

Appelants de conteneurs d'états

Comme nous avons modifié l'API de CraneEditableUserInput, nous devons vérifier partout où elle est appelée pour nous assurer de transmettre les paramètres appropriés.

Dans le projet, cette API n'est appelée que dans le fichier home/SearchUserInput.kt. Ouvrez-le et accédez à la fonction modulable ToDestinationUserInput. vous devriez voir une erreur de compilation. Le hint fait désormais partie du conteneur d'état, et nous souhaitons obtenir un hint personnalisé pour cette instance de CraneEditableUserInput dans la composition. Nous devons mémoriser l'état au niveau ToDestinationUserInput et le transmettre à CraneEditableUserInput :

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

Le code ci-dessus ne comporte pas de fonctionnalité permettant d'avertir l'appelant de ToDestinationUserInput lorsque l'entrée change. En raison de la structure de l'application, nous ne souhaitons pas hisser EditableUserInputState plus haut dans la hiérarchie, car nous souhaitons associer les autres composables tels que FlySearchContent à cet état. Comment appeler le lambda onToDestinationChanged à partir de ToDestinationUserInput tout en conservant ce composable réutilisable ?

Nous pouvons déclencher un effet secondaire à l'aide de LaunchedEffect chaque fois que l'entrée change et appeler le lambda onToDestinationChanged :

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

Nous avons déjà utilisé LaunchedEffect et rememberUpdatedState, mais le code ci-dessus utilise également une nouvelle API. Nous utilisons l'API snapshotFlow pour convertir les objets State<T> Compose en un Flux. Lorsque l'état lu dans snapshotFlow est modifié, le flux émettra la nouvelle valeur pour le collecteur. Dans notre cas, nous convertissons l'état en flux pour utiliser la puissance des opérateurs de flux. Ensuite, nous utilisons un filtre (filter) lorsque text n'est pas hint, et nous récupérons (collect) les éléments émis pour avertir le parent que la destination actuelle a changé.

Cette étape de l'atelier de programmation n'a apporté aucune modification visuelle, mais nous avons amélioré la qualité de cette partie du code. Si vous exécutez l'application maintenant, vous devriez constater que tout fonctionne comme auparavant.

7. DisposableEffect

Lorsque vous appuyez sur une destination, l'écran des détails s'ouvre et vous pouvez voir où se trouve la ville sur la carte. Ce code se trouve dans le fichier details/DetailsActivity.kt. Dans le composable CityMapView, nous appelons la fonction rememberMapViewWithLifecycle. Si vous ouvrez cette fonction, qui se trouve dans le fichier details/MapViewUtils.kt, vous verrez qu'elle n'est connectée à aucun cycle de vie. Elle se souvient simplement d'un MapView et appelle onCreate dessus :

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

Même si l'application fonctionne correctement, c'est un problème, car MapView ne suit pas le bon cycle de vie. Par conséquent, il ne saura pas quand l'application sera déplacée en arrière-plan, quand l'affichage devrait être mis en pause, etc. Trouvons une solution à ce problème !

Étant donné que MapView est une vue et non un composable, nous voulons qu'il suive le cycle de vie de l'activité où il est utilisé plutôt que le cycle de vie de la composition. Nous devons donc créer un LifecycleEventObserver pour écouter les événements de cycle de vie et appeler les bonnes méthodes sur MapView. Nous devons ensuite ajouter cet observateur au cycle de vie de l'activité actuelle.

Commençons par créer une fonction qui renvoie un LifecycleEventObserver qui appelle les méthodes correspondantes dans un MapView en fonction d'un événement donné :

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

Nous devons maintenant ajouter cet observateur au cycle de vie actuel, que nous pouvons obtenir à l'aide du LifecycleOwner actuel avec la composition LocalLifecycleOwner locale. Cependant, il ne suffit pas d'ajouter l'observateur. nous devons aussi être en mesure de le supprimer. Nous avons besoin d'un effet secondaire qui nous indique quand l'effet quitte la composition afin que nous puissions exécuter du code de nettoyage. L'API d'effets secondaires que nous recherchons est DisposableEffect.

DisposableEffect est destiné aux effets secondaires qui doivent être nettoyés après que les touches soient modifiées ou que le composable ait quitté la composition. C'est exactement ce que fait le code final rememberMapViewWithLifecycle. Ajoutez les lignes suivantes à votre projet :

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

L'observateur est ajouté au lifecycle (cycle de vie) actuel, et est supprimé chaque fois que le cycle de vie change ou que ce composable quitte la composition. Avec les key de DisposableEffect, si l'élément lifecycle ou mapView change, l'observateur sera supprimé et ajouté de nouveau au bon lifecycle.

Avec les modifications que nous venons d'apporter, MapView suivra toujours le lifecycle du LifecycleOwner actuel. Il aura le même comportement que s'il avait été utilisé dans une vue.

N'hésitez pas à exécuter l'application et à ouvrir l'écran d'informations pour vérifier que MapView s'affiche toujours correctement. Cette étape n'a apporté aucune modification visuelle.

8. productState

Dans cette section, nous allons améliorer l'affichage de l'écran des détails. Le composable DetailsScreen dans le fichier details/DetailsActivity.kt récupère le cityDetails de manière synchrone à partir de ViewModel et appelle DetailsContent si le résultat aboutit.

Cependant, cityDetails pourrait se révéler plus coûteux à charger sur le thread UI et pourrait utiliser des coroutines pour déplacer le chargement des données vers un autre thread. Améliorons ce code pour ajouter un écran de chargement et afficher DetailsContent lorsque les données sont prêtes.

Pour modéliser l'état de l'écran, vous pouvez utiliser la classe suivante, qui couvre toutes les possibilités : les données à afficher à l'écran, ainsi que les signaux de chargement et d'erreur. Ajoutez la classe DetailsUiState au fichier DetailsActivity.kt :

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

Nous pourrions mapper ce que l'écran doit afficher et l'UiState dans la couche ViewModel à l'aide d'un flux de données, un StateFlow de type DetailsUiState, que le ViewModel met à jour lorsque les informations sont prêtes et que Compose collecte avec l'API collectAsState() que vous connaissez déjà.

Toutefois, pour les besoins de cet exercice, nous allons implémenter une alternative. Si vous souhaitez déplacer la logique de mappage uiState vers l'environnement Compose, vous pouvez utiliser l'API produceState.

produceState vous permet de convertir un état autre que Compose en un état Compose. L'API lance une coroutine limitée à la composition qui peut transférer des valeurs dans le State (état) renvoyé à l'aide de la propriété value. Comme avec LaunchedEffect, produceState utilise également des clés pour annuler et redémarrer le calcul.

Pour notre cas d'utilisation, nous pouvons utiliser produceState pour émettre des mises à jour d'uiState avec une valeur initiale de DetailsUiState(isLoading = true) comme suit :

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

Ensuite, en fonction de uiState, nous affichons les données, l'écran de chargement ou les erreurs. Voici le code complet du composable DetailsScreen :

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

Si vous exécutez l'application, vous verrez comment l'icône de chargement apparaît avant d'afficher les détails de la ville.

aa8fd1ac660266e9.gif

9. derivedStateOf

La dernière amélioration que nous allons apporter à Crane consiste à afficher un bouton Scroll to top (Faire défiler vers le haut) chaque fois que vous faites défiler la liste des destinations de vol après avoir passé le premier élément de l'écran. Appuyer sur le bouton vous permet d'accéder au premier élément de la liste.

2c112d73f48335e0.gif

Ouvrez le fichier base/ExploreSection.kt contenant ce code. Le composable ExploreSection correspond à ce que vous voyez en arrière-plan de l'échafaudage.

La solution proposée dans la vidéo pour implémenter ce comportement ne devrait pas vous surprendre. Cependant, il existe une nouvelle API que nous n'avons pas encore vue et qui est importante dans ce cas d'utilisation : l'API derivedStateOf.

derivedStateOf est utilisé lorsque vous souhaitez un objet Compose State dérivé d'un autre élément State. Cette fonction garantit que le calcul n'aura lieu que lorsque l'un des états utilisés sera modifié.

Déterminer si l'utilisateur a transmis le premier élément à l'aide de listState est aussi simple que vérifier si listState.firstVisibleItemIndex > 0. Cependant, firstVisibleItemIndex est encapsulé dans l'API mutableStateOf, ce qui en fait un état Compose observable. Notre calcul doit également être un état Compose, car nous voulons recomposer l'UI pour afficher le bouton.

Une implémentation simpliste et inefficace ressemblerait à l'exemple suivant. Ne le copiez pas dans votre projet. La bonne implémentation sera copiée par la suite dans votre projet avec le reste de la logique pour l'écran :

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

Une alternative plus efficace consiste à utiliser l'API derivedStateOf qui calcule showButton uniquement lorsque listState.firstVisibleItemIndex change :

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

Vous devriez reconnaître le nouveau code du composable ExploreSection. Redécouvrez comment nous utilisons rememberCoroutineScope pour appeler la fonction de suspension listState.scrollToItem dans le rappel onClick de Button. Nous utilisons une Box pour placer l'élément Button affiché de manière conditionnelle en haut de ExploreList :

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.google.accompanist.insets.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

Si vous exécutez l'application, vous verrez que le bouton s'affiche en bas une fois que vous avez fait défiler le premier élément en dehors de l'écran.

10. Félicitations !

Félicitations, vous avez terminé cet atelier de programmation et appris les concepts avancés des API d'état et d'effets secondaires dans une application Jetpack Compose !

Vous avez appris à créer des conteneurs d'état et des API d'effets secondaires tels que LaunchedEffect, rememberUpdatedState, DisposableEffect, produceState et derivedStateOf, et comment utiliser les coroutines dans Jetpack Compose.

Et maintenant ?

Consultez les autres ateliers de programmation du parcours Compose, ainsi que d'autres exemples de code, dont celui de Crane.

Documentation

Pour en savoir plus et obtenir des conseils à ce sujet, consultez la documentation suivante :