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. Vous verrez comment créer un conteneur d'état pour des composables avec état à la logique complexe, 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.
Pour obtenir de l'aide tout au long de cet atelier de programmation, reportez-vous au code suivant :
Points abordés
- Observer les flux de données du code Compose pour mettre à jour l'interface utilisateur.
- Créer un conteneur d'état pour des composables avec état.
- Les API d'effets secondaires, telles que
LaunchedEffect
,rememberUpdatedState
,DisposableEffect
,produceState
etderivedStateOf
. - Créer des coroutines et appeler des fonctions de suspension dans des composables à l'aide de l'API
rememberCoroutineScope
.
Ce dont vous avez besoin
- Dernière version d'Android Studio
- Connaître la syntaxe du langage Kotlin, y compris les lambdas
- Expérience de base avec Compose. Il peut être utile de suivre l'atelier de programmation sur les principes de base de Jetpack Compose avant cet atelier de programmation.
- Concepts de base sur les états dans Compose, tels que les flux de données unidirectionnels, ViewModel, le hissage d'état, les composables sans état/avec état, les API Slot et les API d'état
remember
etmutableStateOf
. Pour acquérir ces connaissances, vous pouvez consulter la documentation sur l'état et Jetpack Compose ou suivre l'atelier de programmation Utiliser l'état dans Jetpack Compose. - Connaissances de base sur les coroutines Kotlin.
- Connaissances de base sur le cycle de vie des composables.
Objectifs de l'atelier
Dans cet atelier de programmation, vous commencerez avec une application inachevée, l'application Crane, à laquelle vous ajouterez des fonctionnalités pour l'améliorer.
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/android/codelab-android-compose
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.
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.
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 vous allez travailler dans les prochaines étapes de cet atelier de programmation.
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/android/codelab-android-compose
Vous pouvez également télécharger le code de solution ici :
Questions fréquentes
3. Pipeline de production de l'état de l'UI
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 résoudre ce problème, vous devez suivre deux étapes :
- Ajoutez la logique dans
ViewModel
pour générer l'état de l'UI. Dans votre cas, il s'agit de la liste des destinations suggérées. - Utilisez l'état de l'UI, qui affiche l'interface utilisateur à l'écran.
Dans cette section, vous allez effectuer la première étape.
Dans le contexte des applications, une bonne architecture est organisée en couches afin de respecter les bonnes pratiques de base pour la conception du système, comme la séparation des tâches et la testabilité.
La production d'état de l'UI fait référence au processus par lequel l'application accède à la couche de données, applique des règles métier si nécessaire et expose l'état de l'UI à utiliser.
La couche de données de cette application est déjà implémentée. Vous allez maintenant créer l'état (la liste des destinations suggérées) afin que l'UI puisse l'utiliser.
Certaines API permettent de générer l'état de l'UI. Les alternatives sont résumées dans la documentation Types de sortie dans les pipelines de production d'état. En règle générale, il est recommandé d'utiliser StateFlow
de Kotlin pour générer l'état de l'UI.
Pour générer l'état de l'UI, procédez comme suit :
- Ouvrez
home/MainViewModel.kt
. - Définissez une variable
_suggestedDestinations
privée de typeMutableStateFlow
pour représenter la liste des suggestions de destinations, puis définissez une liste vide comme valeur de départ.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
- Définissez une deuxième variable immuable
suggestedDestinations
de typeStateFlow
. Il s'agit de la variable publique en lecture seule qui peut être utilisée à partir de l'UI. Il est recommandé d'exposer une variable en lecture seule tout en utilisant la variable modifiable en interne. De cette manière, vous vous assurez que l'état de l'UI ne peut pas être modifié, sauf s'il est acheminé viaViewModel
, ce qui en fait la source unique de vérité. La fonction d'extensionasStateFlow
convertit le flux de mutable en immuable.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
- Dans le bloc "init" de
ViewModel
, ajoutez un appel à partir dedestinationsRepository
pour obtenir les destinations depuis la couche de données.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
init {
_suggestedDestinations.value = destinationsRepository.destinations
}
- Enfin, annulez la mise en commentaire des utilisations de la variable interne
_suggestedDestinations
que vous trouverez dans cette classe, afin qu'elle puisse être correctement mise à jour avec des événements provenant de l'UI.
Et voilà ! La première étape est terminée. Désormais, ViewModel
peut produire l'état de l'UI. À l'étape suivante, vous utiliserez cet état à partir de l'UI.
4. Utiliser un flux de manière sécurisée à partir de ViewModel
La liste des destinations de vol est toujours vide. À l'étape précédente, vous avez généré l'état de l'UI dans MainViewModel
. Vous allez maintenant utiliser l'état de l'UI exposé par MainViewModel
pour qu'il s'affiche dans l'UI.
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, vous allez corriger et afficher les destinations suggérées par MainViewModel
.
Ouvrez home/MainViewModel.kt
et examinez StateFlow suggestedDestinations
initialisé sur destinationsRepository.destinations
et mis à jour lorsque les fonctions updatePeople
ou toDestinationChanged
sont appelées.
L'UI dans le composable CraneHomeContent
doit être mise à jour chaque fois qu'un nouvel élément est émis dans le flux de données suggestedDestinations
. Vous pouvez utiliser la fonction collectAsStateWithLifecycle()
. collectAsStateWithLifecycle()
collecte les valeurs de StateFlow
et représente la dernière valeur via l'API State de Compose en tenant compte du cycle de vie. Le code Compose qui lit cette valeur d'état se base alors sur ces nouvelles valeurs.
Pour utiliser l'API collectAsStateWithLifecycle
, commencez par ajouter la dépendance suivante dans app/build.gradle
. La variable lifecycle_version
est déjà définie dans le projet avec la version appropriée.
dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}
Revenez au composable CraneHomeContent
et remplacez la ligne qui attribue suggestedDestinations
par un appel à collectAsStateWithLifecycle
sur la propriété suggestedDestinations
de ViewModel
:
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun CraneHomeContent(
onExploreItemClicked: OnExploreItemClicked,
openDrawer: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MainViewModel = viewModel(),
) {
val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
// ...
}
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.
5. LaunchedEffect et rememberUpdatedState
Le projet contient un fichier home/LandingScreen.kt
qui n'est pas utilisé pour le moment. Vous voulez 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, vous afficheriez l'écran, et, une fois les données chargées, indiqueriez à 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, vous utiliserez la fonction delay
des coroutines pour simuler le chargement d'éléments en arrière-plan.
Un effet secondaire dans Compose est un changement de l'é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
. De plus, puisqu'avant l'appel à onTimeout
vous devez 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. Vous appellerez le composable LandingScreen
plus tard dans cette étape.
// home/LandingScreen.kt file
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
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 ? Il n'est pas conseillé de redémarrer LaunchedEffect
si les appelants de cette fonction modulable transmettent une valeur lambda onTimeout
différente. Le delay
pourrait alors redémarrer. et vous ne rempliriez plus les conditions requises.
Résolvons à présent ce problème. 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(Unit) { ... }
. Toutefois, un autre problème se présente.
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 vous assurer que le dernier onTimeout
est appelé, n'oubliez pas onTimeout
avec l'API rememberUpdatedState
. Cette API capture et met à jour la valeur la plus récente :
// home/LandingScreen.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
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(Unit) {
delay(SplashWaitTime)
currentOnTimeout()
}
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
Vous devez utiliser rememberUpdatedState
lorsqu'une expression lambda ou d'objet de longue durée fait référence à des paramètres ou à des valeurs calculées lors de la composition, ce qui peut être courant avec LaunchedEffect
.
Afficher l'écran de destination
Vous devez 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
, vous pouvez 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.
6. rememberCoroutineScope
Au cours de cette étape, vous allez 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ù vous devez ouvrir le panneau de navigation. Dans le rappel openDrawer
!
Dans CraneHome
, vous avez 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. Ce que vous pouvez faire openDrawer
est une fonction de rappel simple, ainsi :
- Vous ne pouvez pas simplement appeler des fonctions de suspension, car
openDrawer
n'est pas exécuté dans le contexte d'une coroutine. - Vous ne pouvez pas utiliser
LaunchedEffect
comme vous l'aviez fait précédemment, car vous ne pouvez pas appeler de composables dansopenDrawer
. Nous ne sommes pas dans la composition.
Votre 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. L'API rememberCoroutineScope
renvoie un CoroutineScope
lié au point dans la composition où vous l'appelez. 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.
LaunchedEffect vs rememberCoroutineScope
Dans ce cas, utiliser LaunchedEffect
était impossible, car vous deviez 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é.
7. 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.
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, créez une classe standard en tant que 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)
private set
fun updateText(newText: String) {
text = newText
}
val isHint: Boolean
get() = text == hint
}
La classe doit présenter les caractéristiques suivantes :
text
est un état modifiable de typeString
, tout comme dansCraneEditableUserInput
. Il est important d'utilisermutableStateOf
afin que Compose suive les modifications apportées à la valeur et se recompose lorsque des modifications se produisent.text
est unevar
, avec unset
privé, et ne peut donc pas être transféré directement depuis l'extérieur de la classe. Au lieu de rendre cette variable publique, vous pouvez exposer un événementupdateText
pour la modifier, ce qui fait de la classe sa source unique de référence.- La classe utilise un
initialText
comme dépendance pour initialisertext
. - 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 vous 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 vous n'enregistrez (remember
) que cet état, il ne survivra pas à la recréation de l'activité. Pour ce faire, vous pouvez 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 vous avez créée dans votre projet. Vous devez 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 ce cas, au lieu de créer une implémentation personnalisée de Saver
pour la classe EditableUserInputState
, vous pouvez 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, ajoutez 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, vous utilisez un listSaver
comme détail d'implémentation pour stocker et restaurer une instance de EditableUserInputState
dans le saver.
Vous pouvez 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
Vous allez utiliser EditableUserInputState
au lieu de text
et de isHint
, mais vous ne voulez 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, vous souhaitez hisser EditableUserInputState
afin que les appelants puissent contrôler l'état de CraneEditableUserInput
. Si vous hissez 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, vous devez modifier les paramètres de la fonction modulable et lui attribuer une valeur par défaut si nécessaire. Puisque vous souhaitez 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.
Vous devez 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.updateText(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 vous avez modifié l'API de CraneEditableUserInput
, vous devez vérifier tous les endroits où elle est appelée pour vous 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 vous souhaitez obtenir un hint personnalisé pour cette instance de CraneEditableUserInput
dans la composition. Vous devez 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. Compte tenu de la structure de l'application, vous ne souhaitez pas hisser EditableUserInputState
plus haut dans la hiérarchie. Vous ne voulez pas associer les autres composables tels que FlySearchContent
à cet état. Comment appeler le lambda onToDestinationChanged
à partir de ToDestinationUserInput
tout en conservant ce composable réutilisable ?
Vous pouvez 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)
}
}
}
Vous avez déjà utilisé LaunchedEffect
et rememberUpdatedState
, mais le code ci-dessus utilise également une nouvelle API. L'API snapshotFlow
convertit 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 ce cas, vous convertissez l'état en flux pour utiliser la puissance des opérateurs de flux. Ensuite, vous utilisez un filtre (filter
) lorsque text
n'est pas hint
, et vous récupérez (avec 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 vous avez amélioré la qualité de cette partie du code. Si vous exécutez l'application maintenant, vous devriez constater que tout fonctionne comme auparavant.
8. 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
, vous appelez 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, vous voulez qu'il suive le cycle de vie de l'activité où il est utilisé au lieu du cycle de vie de la composition. Vous devez donc créer un LifecycleEventObserver
pour écouter les événements de cycle de vie et appeler les bonnes méthodes sur MapView
. Vous devez ensuite ajouter cet observateur au cycle de vie de l'activité actuelle.
Commencez 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()
}
}
Vous devez maintenant ajouter cet observateur au cycle de vie actuel, que vous pouvez obtenir à l'aide du LifecycleOwner
actuel avec la composition LocalLifecycleOwner
locale. Cependant, il ne suffit pas d'ajouter l'observateur. Vous devez aussi être en mesure de le supprimer. Vous avez besoin d'un effet secondaire qui vous indique quand l'effet quitte la composition afin que vous puissiez exécuter du code de nettoyage. L'API d'effets secondaires que vous recherchez 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 vous venez 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.
9. produceState
Dans cette section, vous allez 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. Vous allez améliorer 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
)
Vous pourriez 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 collectAsStateWithLifecycle()
que vous connaissez déjà.
Toutefois, pour les besoins de cet exercice, vous allez 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 votre cas d'utilisation, vous pouvez 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
, vous affichez 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.
10. derivedStateOf
La dernière amélioration que vous allez 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 dépassé le premier élément de l'écran. Appuyer sur le bouton vous permet d'accéder au premier élément de la liste.
Ouvrez le fichier base/ExploreSection.kt
contenant ce code. Le composable ExploreSection
correspond à ce que vous voyez en arrière-plan de l'échafaudage.
Pour déterminer si l'utilisateur a dépassé le premier élément, utilisez LazyListState
de LazyColumn
et vérifiez si listState.firstVisibleItemIndex > 0
.
Une implémentation simple se présente comme suit :
// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0
Cette solution n'est pas aussi efficace qu'elle pourrait l'être, car la fonction modulable qui lit showButton
se recompose aussi souvent que firstVisibleItemIndex
change, ce est fréquent en cas de défilement. L'objectif est de faire en sorte que la fonction se recompose uniquement lorsque la condition passe de true
à false
, et vice versa.
L'API derivedStateOf
permet d'atteindre cet objectif.
listState
est un état (State
) Compose observable. Votre calcul, showButton
, doit également être un State
de Compose, car vous voulez que l'UI se recompose lorsque sa valeur change, et afficher ou masquer le bouton.
Utilisez derivedStateOf
lorsque vous souhaitez un objet Compose State
dérivé d'un autre élément State
. Le bloc de calcul derivedStateOf
est exécuté chaque fois que l'état interne change, mais la fonction modulable ne se recompose que lorsque le résultat du calcul est différent du précédent. Cela réduit le nombre de fois où les fonctions qui lisent showButton
sont recomposées.
Dans ce cas, l'utilisation de l'API derivedStateOf
est une alternative plus efficace. Vous allez également encapsuler l'appel avec l'API remember
afin que la valeur calculée survive à la recomposition.
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
Vous devriez reconnaître le nouveau code du composable ExploreSection
. Vous utilisez une Box
pour placer l'élément Button
affiché de manière conditionnelle en haut de ExploreList
. Vous utilisez aussi rememberCoroutineScope
pour appeler la fonction de suspension listState.scrollToItem
dans le rappel onClick
de Button
.
// 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 androidx.compose.foundation.layout.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.
11. 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 :