Utiliser l'état dans Jetpack Compose

1. Introduction

Dans cet atelier de programmation, vous allez découvrir la notion d'état et la façon dont Jetpack Compose peut l'utiliser et la manipuler.

Avant de nous lancer, il est utile de définir précisément cette notion d'état. En pratique, on entend par "état" toute valeur pouvant évoluer au fil du temps dans une application. C'est une définition très large qui recouvre aussi bien une base de données Room qu'une variable dans une classe.

Toutes les applications Android présentent des états à l'utilisateur. Voici quelques exemples d'états dans les applications Android :

  1. Un snackbar qui indique quand une connexion réseau ne peut pas être établie
  2. Un article de blog et les commentaires associés
  3. Des animations de boutons produisant un effet d'ondes en cas d'activation par l'utilisateur
  4. Des autocollants qu'un utilisateur peut superposer à une image

Dans cet atelier de programmation, vous apprendrez comment envisager et exploiter les états lorsque vous utilisez Jetpack Compose. Pour ce faire, nous allons créer une application de liste de tâches. À la fin de cet atelier de programmation, vous aurez créé une UI avec état affichant une liste interactive de tâches.

b5c4dc05d1e54d5a.png

Dans la section suivante, vous découvrirez le flux de données unidirectionnel. Ce système de conception est essentiel pour comprendre comment afficher et gérer les états dans Compose.

Points abordés

  • Définition d'un flux de données unidirectionnel
  • Comment envisager le rôle des états et des événements dans une UI
  • Comment utiliser les éléments ViewModel et LiveData du composant d'architecture dans Compose pour gérer les états
  • Comment Compose utilise les états pour afficher des éléments sur un écran
  • Quand déplacer un état vers un appelant
  • Comment utiliser un état interne dans Compose
  • Comment utiliser State<T> pour intégrer un état dans Compose

Prérequis

Objectifs de l'atelier

  • Application de listes de tâches interactive utilisant un flux de données unidirectionnel dans Compose

2. Configuration

Pour télécharger l'application exemple, vous pouvez :

Télécharger le fichier ZIP

Vous pouvez également cloner le dépôt GitHub à partir de la ligne de commande à l'aide de la commande suivante :

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

À tout moment, vous pouvez exécuter l'un ou l'autre de ces modules dans Android Studio en modifiant la configuration d'exécution dans la barre d'outils.

b059413b0cf9113a.png

Ouvrir le projet dans Android Studio

  1. Dans la fenêtre "Welcome to Android Studio" (Bienvenue dans Android Studio), sélectionnez c01826594f360d94.png Open an existing Project (Ouvrir un projet existant).
  2. Sélectionnez le dossier [Download Location]/StateCodelab. (conseil : assurez-vous de sélectionner le répertoire StateCodelab contenant build.gradle.)
  3. Une fois qu'Android Studio a importé le projet, vérifiez que vous pouvez exécuter les modules start et finished.

Analyser le code de départ

Le code de départ contient quatre packages :

  • examples : exemples d'activités permettant d'explorer les concepts liés au flux de données unidirectionnel. Vous n'aurez pas à intervenir sur ce package.
  • ui : contient les thèmes générés automatiquement par Android Studio lors du démarrage d'un nouveau projet Compose. Vous n'aurez pas à intervenir sur ce package.
  • util : contient du code utile au projet. Vous n'aurez pas à intervenir sur ce package.
  • todo : package contenant le code de l'écran de liste de tâches que nous allons créer. C'est dans ce package que vous allez apporter des modifications.

Cet atelier de programmation porte essentiellement sur les fichiers du package todo. Le module start contient plusieurs fichiers avec lesquels vous devez vous familiariser.

Fichiers fournis dans le package todo

  • Data.kt : structures de données utilisées pour représenter un élément TodoItem.
  • TodoComponents.kt : composables réutilisables que vous utiliserez pour créer l'écran de liste de tâches. Vous n'aurez pas à intervenir sur ce fichier.

Fichiers que vous modifierez dans le package todo

  • TodoActivity.kt : activité Android utilisant Compose pour afficher un écran de liste de tâches une fois que vous aurez terminé cet atelier de programmation.
  • TodoViewModel.kt : un élément ViewModel que vous allez intégrer avec Compose pour créer l'écran de liste de tâches. Vous l'associerez à Compose et l'étendrez pour ajouter des fonctionnalités dans le cadre de cet atelier de programmation.
  • TodoScreen.kt : implémentation dans Compose d'un écran de liste de tâches que vous allez créer dans cet atelier de programmation.

3. Comprendre ce qu'est un flux de données unidirectionnel

L'actualisation en continu de l'UI

Avant d'accéder à l'application de liste de tâches, nous allons explorer les concepts associés au flux de données unidirectionnel à l'aide du système Android View.

Comment s'effectuent les modifications d'état ? Dans l'introduction, nous avons parlé de l'état comme d'une valeur qui change au fil du temps. Ceci ne décrit qu'un aspect du concept d'état dans une application Android.

Dans les applications Android, l'état est modifié en réponse à des événements. Les événements sont des entrées générées en dehors de notre application, comme lorsque l'utilisateur appuie sur un bouton appelant un OnClickListener, quand un EditText appelle un élément afterTextChanged ou lorsqu'un accéléromètre envoie une nouvelle valeur.

Dans toutes les applications Android, il existe une boucle centrale d'actualisation de l'UI qui se présente ainsi :

f415ca9336d83142.png

  • Événement : un événement est généré par l'utilisateur ou une autre partie du programme.
  • Modification d'état : un gestionnaire d'événements modifie l'état utilisé par l'UI.
  • Affichage d'état : l'UI est actualisée pour afficher le nouvel état.

La gestion de l'état dans Compose permet de comprendre comment les états et les événements interagissent entre eux.

État non structuré

Avant d'aborder le cas de Compose, examinons les événements et les états dans le système Android View. Pour créer un état "Hello World", nous allons créer un élément Activity permettant à l'utilisateur de saisir son nom.

879ed27ccab2eed3.gif

Une façon de programmer cela consiste à demander au rappel d'événement de définir directement l'état dans TextView. Le code, qui utilise ViewBinding, peut alors se présenter comme suit :

HelloCodelabActivity**.kt**

class HelloCodelabActivity : AppCompatActivity() {

   private lateinit var binding: ActivityHelloCodelabBinding
   var name = ""

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */
       binding.textInput.doAfterTextChanged {text ->
           name = text.toString()
           updateHello()
       }
   }

   private fun updateHello() {
       binding.helloText.text = "Hello, $name"
   }
}

Ce code fonctionne correctement et convient parfaitement pour un petit exemple comme celui-ci. Toutefois, il devient plus difficile à gérer si l'UI devient plus complexe.

Si vous ajoutez un certain nombre d'événements et d'états à une activité ainsi créée, plusieurs problèmes peuvent survenir :

  1. Tests : étant donné que l'état de l'UI est intégré à Views, il peut être difficile de tester ce code.
  2. Modifications partielles de l'état : lorsque l'écran comporte beaucoup d'autres événements, il est facile d'oublier d'actualiser une partie de l'état en réponse à un événement. L'UI présentée à l'utilisateur peut alors être incohérente ou incorrecte.
  3. Modifications partielles de l'UI : puisque nous mettons à jour manuellement l'UI après chaque changement d'état, le risque d'oubli est élevé. L'utilisateur peut alors voir des données obsolètes dans l'UI actualisée de manière aléatoire.
  4. Complexité du code : il est difficile d'extraire une partie de la logique lors du codage avec ce système. Le code est alors souvent plus difficile à lire et à interpréter.

Utiliser le flux de données unidirectionnel

Pour résoudre ces problèmes d'états non structurés, nous avons créé les composants d'architecture Android qui contiennent les éléments ViewModel et LiveData.

Une classe ViewModel vous permet d'extraire un état de votre UI et de définir des événements que l'UI peut appeler pour actualiser cet état. Examinons ce qui se produit quand la même activité est codée avec un élément ViewModel.

8a331b9c1b392bef.png

HelloCodelabActivity.kt

class HelloCodelabViewModel: ViewModel() {

   // LiveData holds state which is observed by the UI
   // (state flows down from ViewModel)
   private val _name = MutableLiveData("")
   val name: LiveData<String> = _name

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(newName: String) {
       _name.value = newName
   }
}

class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
   private val helloViewModel by viewModels<HelloCodelabViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */

       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

Dans cet exemple, nous avons déplacé l'état de Activity vers ViewModel. Dans un ViewModel, l'état est représenté par LiveData. Un LiveData est un conteneur d'état observable, ce qui signifie qu'il permet à quiconque d'observer les modifications apportées à l'état. Ensuite, dans l'UI, nous utilisons la méthode observe pour actualiser l'UI chaque fois que l'état change.

L'élément ViewModel expose également un événement : onNameChanged. Cet événement est appelé par l'UI en réponse à des événements utilisateur, comme ce qui se produit ici chaque fois que le texte de EditText change.

Revenons à la boucle d'actualisation de l'UI dont nous avons parlé précédemment, pour voir comment ViewModel interagit avec les événements et l'état.

  • Événement : onNameChanged est appelé par l'UI lorsque le texte saisi est modifié.
  • Modification d'état : onNameChanged traite les données, puis définit l'état de _name.
  • Affichage d'état : les observateurs de name sont appelés, ce qui informe l'UI des changements d'état.

En structurant notre code de cette manière, nous pouvons envisager une "montée" des événements vers ViewModel. Ensuite, en réponse aux événements, l'élément ViewModel effectue un traitement, voire une modification de l'état. Une fois l'état actualisé, il "redescend" jusqu'à l'élément Activity.

L&#39;état descend de l&#39;élément ViewModel vers l&#39;activité, tandis que les événements remontent de l&#39;activité à ViewModel.

Ce modèle est appelé flux de données unidirectionnel. Un flux de données unidirectionnel est un système dans lequel l'état descend et les événements remontent. En structurant notre code de cette manière, nous obtenons certains avantages :

  • Facilité des tests : en dissociant l'état de l'UI qui l'affiche, il est plus facile de tester ViewModel et l'activité.
  • Encapsulation de l'état : comme l'état peut uniquement être actualisé à un seul endroit (ViewModel), il y aura moins de risque d'introduire un bug d'actualisation lorsque l'UI deviendra plus complexe.
  • Cohérence de l'UI : toutes les modifications d'état sont immédiatement reflétées dans l'UI grâce à l'utilisation de conteneurs d'état observables.

Bien que cette approche ajoute un peu de code, elle permet de gérer plus facilement et plus efficacement les états et les événements complexes à l'aide d'un flux de données unidirectionnel.

Dans la section suivante, nous verrons comment utiliser ce flux de données unidirectionnel dans Compose.

4. Compose et les éléments ViewModel

Dans la section précédente, nous avons exploré le flux de données unidirectionnel dans le système Android View à l'aide de ViewModel et LiveData. Nous allons à présent passer à Compose et découvrir comment utiliser le flux de données unidirectionnel dans Compose à l'aide d'éléments ViewModels.

À la fin de cette section, vous aurez créé cet écran :

7998ef0a441d4b3.png

Découvrez les composables TodoScreen

Le code que vous avez téléchargé contient plusieurs composables que vous utiliserez et modifierez tout au long de cet atelier.

Ouvrez TodoScreen.kt et examinez le composable TodoScreen existant :

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   /* ... */
}

Pour afficher ce composable, activez le volet de prévisualisation d'Android Studio en cliquant sur l'icône de division d'écran située en haut à droite 52dd4dd99bae0aaf.png.

4cedcddc3df7c5d6.png

Ce composable affiche une liste de tâches modifiable, mais n'a pas d'état propre. N'oubliez pas que l'état est une valeur susceptible de changer, or aucun des arguments de TodoScreen ne peut être modifié.

  • items : liste d'éléments immuables à afficher à l'écran
  • onAddItem : événement activé quand l'utilisateur demande l'ajout d'une tâche
  • onRemoveItem : événement activé quand l'utilisateur demande la suppression d'une tâche

En fait, ce composable peut être sans état. Il affiche uniquement la liste des éléments qui ont été transmis et ne permet pas de la modifier directement. Au lieu de cela, il peut recevoir deux événements onRemoveItem et onAddItem qui peuvent demander des modifications.

On peut alors se poser une question : s'il est sans état, comment peut-il afficher une liste modifiable ? Pour ce faire, il utilise une technique appelée hissage d'état. Le hissage d'état est le système qui consiste à faire monter un état pour rendre un composant sans état. Les composants sans état sont plus faciles à tester, ont souvent moins de bugs et offrent davantage de possibilités de réutilisation.

La combinaison de ces paramètres permet à l'appelant de hisser l'état hors de ce composable. Pour voir comment cela fonctionne, nous allons analyser la boucle d'actualisation de l'UI de ce composable.

  • Événement : lorsque l'utilisateur demande l'ajout ou la suppression d'un élément, TodoScreen appelle onAddItem ou onRemoveItem.
  • Modification d'état : l'appelant de TodoScreen peut répondre à ces événements en actualisant l'état
  • Affichage d'état : lorsque l'état est actualisé, TodoScreen est appelé de nouveau avec le nouveau items pour que ces éléments s'affichent à l'écran.

L'appelant doit déterminer où et comment conserver la valeur de cet état. Il peut stocker des éléments items selon la méthode la plus pratique, par exemple en mémoire, ou les lire depuis une base de données Room. TodoScreen est complètement dissocié de la méthode choisie pour assurer la gestion de l'état.

Définir le composable TodoActivityScreen

Ouvrez TodoViewModel.kt, puis trouvez un élément ViewModel existant qui définit une variable d'état et deux événements.

TodoViewModel.kt

class TodoViewModel : ViewModel() {

   // state: todoItems
   private var _todoItems = MutableLiveData(listOf<TodoItem>())
   val todoItems: LiveData<List<TodoItem>> = _todoItems

   // event: addItem
   fun addItem(item: TodoItem) {
        /* ... */
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
        /* ... */
   }
}

Nous souhaitons utiliser cet élément ViewModel pour hisser l'état hors de TodoScreen. Lorsque ce sera fait, nous aurons créé un système de flux de données unidirectionnel semblable à ceci :

f555d7b9be40144c.png

Pour démarrer l'intégration de TodoScreen dans TodoActivity, ouvrez TodoActivity.kt et définissez une nouvelle fonction @Composable TodoActivityScreen(todoViewModel: TodoViewModel), puis appelez-la à partir de setContent dans onCreate.

Dans la suite de cette section, nous allons créer TodoActivityScreen une étape à la fois. Vous pouvez commencer par appeler TodoScreen avec un état et des événements factices comme ci-après :

TodoActivity.kt

import androidx.compose.runtime.Composable

class TodoActivity : AppCompatActivity() {

   private val todoViewModel by viewModels<TodoViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           StateCodelabTheme {
               Surface {
                   TodoActivityScreen(todoViewModel)
               }
           }
       }
   }
}

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>() // in the next steps we'll complete this
   TodoScreen(
       items = items,
       onAddItem = { }, // in the next steps we'll complete this
       onRemoveItem = { } // in the next steps we'll complete this
   )
}

Ce composable peut servir de pont entre l'état stocké dans ViewModel et le composable TodoScreen déjà défini dans le projet. Vous pouvez modifier TodoScreen pour prendre l'élément ViewModel directement, mais TodoScreen ne sera pas aussi facile à réutiliser. En optant pour des paramètres plus simples tels que List<TodoItem>, vous évitez d'associer TodoScreen à l'emplacement spécifique où l'état est hissé.

Si vous exécutez l'application maintenant, vous verrez qu'elle affiche un bouton, mais cliquer dessus n'a aucun effet. C'est dû au fait que nous n'avons pas encore connecté ViewModel à TodoScreen.

a195c5b4d2a5ea0f.png

Faire circuler les événements vers le haut

Maintenant que nous avons tous les éléments nécessaires (un ViewModel, un composable TodoActivityScreen pour faire office de pont et TodoScreen), connectons-les pour afficher une liste dynamique à l'aide d'un flux de données unidirectionnel.

Dans TodoActivityScreen, transmettez addItem et removeItem à partir de ViewModel.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>()
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

Lorsque TodoScreen appelle onAddItem ou onRemoveItem, nous pouvons transmettre l'appel à l'événement approprié sur notre ViewModel.

Faire redescendre l'état

Nous avons configuré les événements de notre flux de données unidirectionnel. Nous devons maintenant faire redescendre l'état.

Modifiez TodoActivityScreen pour observer l'élément LiveData de todoItems à l'aide de observeAsState :

TodoActivity.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

Cette ligne observe l'élément LiveData et permet d'utiliser directement la valeur actuelle en tant que List<TodoItem>.

C'est une ligne qui contient beaucoup d'éléments que nous pouvons analyser un à un :

  • val items: List<TodoItem> déclare une variable items de type List<TodoItem>.
  • todoViewModel.todoItems est un élément LiveData<List<TodoItem> du ViewModel.
  • .observeAsState observe un élément LiveData<T> et le convertit en objet State<T> afin que Compose puisse réagir aux changements de valeur.
  • listOf() est une valeur initiale permettant d'éviter d'éventuels résultats null avant l'initialisation de LiveData. Si cette valeur n'était pas transmise, les éléments items correspondraient à List<TodoItem>?, qui peut avoir une valeur nulle.
  • by est la syntaxe de délégation de propriété dans Kotlin. Elle nous permet de désencapsuler automatiquement le State<List<TodoItem>> de observeAsState pour en faire un List<TodoItem> standard.

Exécuter à nouveau l'application

Exécutez de nouveau l'application pour afficher une liste s'actualisant de façon dynamique. Si vous cliquez sur le bouton situé en bas, des tâches sont ajoutées. Si vous cliquez sur une tâche, celle-ci est supprimée.

7998ef0a441d4b3.png

Dans cette section, nous avons vu comment concevoir un système de flux de données unidirectionnel dans Compose à l'aide de ViewModels. Nous avons également vu comment utiliser un composable sans état permettant d'afficher une UI avec état à l'aide d'une technique appelée hissage d'état. Nous avons également continué à envisager les UI dynamiques en termes d'état et d'événements.

Dans la section suivante, nous allons apprendre à ajouter de la mémoire aux fonctions modulables.

5. Mémoire dans Compose

Maintenant que nous avons vu comment utiliser Compose avec ViewModel pour créer un flux de données unidirectionnel, nous allons voir comment Compose peut interagir avec un état de façon interne.

Dans la section précédente, vous avez vu comment Compose actualise l'écran en appelant à nouveau des composables. Ce processus est appelé recomposition. Nous avons pu afficher une liste dynamique en appelant à nouveau TodoScreen.

Dans cette section, nous verrons comment créer des composables avec état.

Dans cette section, nous allons apprendre comment ajouter de la mémoire à une fonction modulable. Cette étape importante nous sera nécessaire pour ajouter un état dans Compose à la section suivante.

Design déjanté

Maquette du concepteur

40a46273d161497a.png

Dans cette section, un nouveau concepteur de votre équipe vous a envoyé un projet de maquette inspiré d'une tendance en vogue, le design déjanté. Le principe de base du design déjanté consiste à adopter une conception éprouvée, puis à y ajouter des modifications d'apparence aléatoire afin de lui donner un côté intéressant.

Dans cette conception, la couche alpha de chaque icône reçoit une valeur aléatoire comprise entre 0,3 et 0,9.

Ajout d'un nombre aléatoire à un composable

Pour commencer, ouvrez TodoScreen.kt et trouvez le composable TodoRow. Ce composable peut décrire une seule ligne de la liste des tâches.

Définissez un nouvel élément val iconAlpha avec la valeur randomTint(). Cela correspond à une valeur flottante comprise entre 0,3 et 0,9, comme l'a demandé notre concepteur. Définissez ensuite la teinte de l'icône.

TodoScreen.kt

import androidx.compose.material.LocalContentColor

@Composable
fun TodoRow(todo: TodoItem, onItemClicked: (TodoItem) -> Unit, modifier: Modifier = Modifier) {
   Row(
       modifier = modifier
           .clickable { onItemClicked(todo) }
           .padding(horizontal = 16.dp, vertical = 8.dp),
       horizontalArrangement = Arrangement.SpaceBetween
   ) {
       Text(todo.task)
       val iconAlpha = randomTint()
       Icon(
           imageVector = todo.icon.imageVector,
           tint = LocalContentColor.current.copy(alpha = iconAlpha),
           contentDescription = stringResource(id = todo.icon.contentDescription)
       )
   }
}

Si vous vérifiez à nouveau l'aperçu, vous constaterez que l'icône a désormais une teinte aléatoire.

cdb483885e713651.png

Découvrir la recomposition

Exécutez à nouveau l'application pour tester le nouveau design déjanté. Vous remarquerez immédiatement que les teintes ne cessent de changer. Le concepteur reconnaît que malgré l'indéniable beauté du hasard, l'effet n'est pas idéal.

Application dont les icônes changent de teinte en cas de modification de la liste

2e53e9411aeee11e.gif

Que voit-on ici ? Il s'avère que le processus de recomposition appelle randomTint pour chaque ligne à l'écran pour tout changement dans la liste.

La recomposition désigne le processus qui consiste à appeler de nouveau les composables avec de nouvelles entrées pour actualiser l'arborescence de composition. Dans ce cas, lorsque TodoScreen est appelé à nouveau avec une nouvelle liste, LazyColumn recompose tous les enfants à l'écran. Ceci entraîne un nouvel appel de TodoRow, ce qui génère une nouvelle teinte aléatoire.

Compose génère une arborescence, mais elle n'est pas tout à fait identique à l'arborescence d'UI que vous avez peut-être déjà rencontrée dans le système Android View. Au lieu d'une arborescence de widgets d'UI, Compose génère une arborescence de composables. Nous pouvons visualiser TodoScreen comme ceci :

Arborescence de TodoScreen

6f5faa4342c63d88.png

Lorsque Compose exécute la composition pour la première fois, il crée une arborescence de tous les composables appelés. Ensuite, lors de la recomposition, Compose actualise l'arborescence avec les nouveaux composables appelés.

La raison pour laquelle les icônes sont actualisées chaque fois que l'élément TodoRow est recomposé est que TodoRow a un effet secondaire masqué. Un effet secondaire désigne une modification visible en dehors de l'exécution d'une fonction modulable.

L'appel de Random.nextFloat() actualise la variable interne aléatoire utilisée dans un générateur de nombres pseudo-aléatoires. C'est ainsi que Random renvoie une valeur différente chaque fois que vous demandez un nombre aléatoire.

Présentation de la mémoire pour les fonctions modulables

Nous ne voulons pas que la teinte change à chaque recomposition de TodoRow. Nous avons donc besoin d'un emplacement permettant de nous souvenir de la teinte utilisée dans la dernière composition. Compose nous permet de stocker des valeurs dans l'arborescence de composition. Nous pouvons donc actualiser TodoRow pour stocker iconAlpha dans l'arborescence de composition.

Modifiez TodoRow et faites précéder l'appel de randomTint par remember comme suit :

TodoScreen.kt

val iconAlpha: Float = remember(todo.id) { randomTint() }
Icon(
   imageVector = todo.icon.imageVector,
   tint = LocalContentColor.current.copy(alpha = iconAlpha),
   contentDescription = stringResource(id = todo.icon.contentDescription)
)

En examinant la nouvelle arborescence de composition pour TodoRow, vous pouvez constater que iconAlpha y a été ajouté :

Arborescence TodoRow avec remember

Schéma représentant l&#39;élément iconAlpha comme nouvel enfant de TodoRow dans l&#39;arborescence de composition.

Si vous exécutez à nouveau l'application, vous verrez que la teinte n'est plus modifiée à chaque changement dans la liste. Au lieu de cela, lors de la recomposition, la valeur précédente stockée par remember est renvoyée.

En examinant attentivement l'appel, vous pouvez constater que nous transmettons todo.id en tant qu'argument de clé (key).

remember(todo.id) { randomTint() }

Un appel remember contient deux parties :

  1. Des arguments de clé : il s'agit de la "clé" utilisée par cet appel remember et qui est indiquée entre parenthèses. Ici, cette clé est todo.id.
  2. Un calcul : un lambda qui calcule une nouvelle valeur à mémoriser, transmis dans un lambda placé à la suite. Ici, nous calculons une valeur aléatoire avec randomTint().

La première fois que cela se produit, remember appelle toujours randomTint et mémorise le résultat pour la recomposition suivante. Il enregistre également l'élément todo.id qui a été transmis. Lors de la recomposition, il ignorera l'appel de randomTint et renverra la valeur mémorisée, sauf si une nouvelle valeur todo.id a été transmise à TodoRow.

La recomposition d'un composable doit être idempotente. En encadrant l'appel de randomTint avec remember, nous ignorons l'appel à une valeur aléatoire lors de la recomposition, sauf en cas de modification des tâches de la liste. Par conséquent, TodoRow n'a aucun effet secondaire et produit toujours le même résultat chaque fois qu'il se recompose avec la même entrée et est idempotent.

Rendre les valeurs mémorisées contrôlables

Si vous exécutez l'application maintenant, vous verrez qu'elle affiche une teinte aléatoire sur chaque icône. Le concepteur est ravi de constater qu'elle respecte les principes du design déjanté et la valide pour livraison.

Cependant, une petite modification de code doit être effectuée avant de boucler le projet. Pour le moment, l'appelant de TodoRow ne peut pas spécifier la teinte. Cette option peut être intéressante pour plusieurs raisons. Par exemple, un directeur produit peut remarquer cet écran et exiger un correctif pour supprimer la dimension déjantée juste avant la livraison de l'application.

Pour permettre à l'appelant de contrôler cette valeur, il vous suffit de déplacer l'appel de remember vers un argument par défaut dans un nouveau paramètre iconAlpha.

@Composable
fun TodoRow(
   todo: TodoItem,
   onItemClicked: (TodoItem) -> Unit,
   modifier: Modifier = Modifier,
   iconAlpha: Float = remember(todo.id) { randomTint() }
) {
   Row(
       modifier = modifier
           .clickable { onItemClicked(todo) }
           .padding(horizontal = 16.dp)
           .padding(vertical = 8.dp),
       horizontalArrangement = Arrangement.SpaceBetween
   ) {
       Text(todo.task)
       Icon(
            imageVector = todo.icon.imageVector,
            tint = LocalContentColor.current.copy(alpha = iconAlpha),
            contentDescription = stringResource(id = todo.icon.contentDescription)
        )
   }
}

L'appelant obtient désormais le même comportement par défaut : TodoRow calcule une valeur randomTint. Mais il peut spécifier la valeur alpha de son choix. En permettant à l'appelant de contrôler alphaTint, ce composable devient plus réutilisable. Sur un autre écran, il est possible que le concepteur souhaite afficher toutes les icônes avec un alpha de valeur 0,7.

Il existe également un bug très subtil dans notre utilisation de remember. Essayez d'ajouter suffisamment de tâches pour faire sortir quelques éléments de l'écran en cliquant de manière répétée sur "Add random todo" ("Ajouter une tâche aléatoire"), puis en faisant défiler la page. Lorsque vous faites défiler la page, vous remarquez que la valeur alpha des icônes change chaque fois que le défilement les fait réapparaître à l'écran.

Dans les sections suivantes, nous allons nous pencher sur l'état et le hissage d'état. Ceci vous fournira les outils dont vous avez besoin pour corriger des bugs comme celui que nous venons de décrire.

6. L'état dans Compose

Dans la section précédente, nous avons appris comment fonctionne la mémoire des fonctions modulables. Nous allons maintenant explorer comment utiliser cette mémoire pour ajouter un état à un composable.

Entrée de la liste de tâches (état : développée) 721446d6a55fcaba.png

Entrée de la liste de tâches (état : réduite) 6f46071227df3625.png

Notre concepteur a abandonné le design déjanté en faveur d'une approche post-Material Design. La nouvelle interface de saisie des tâches occupe le même espace qu'un en-tête réductible et présente deux états principaux : développé et réduit. La version développée s'affiche lorsque le texte n'est pas vide.

Pour créer cela, nous allons d'abord créer le texte et le bouton. Ensuite, nous ajouterons des icônes à masquage.

La modification de texte dans une UI est une opération avec état. L'utilisateur actualise le texte affiché chaque fois qu'il saisit un caractère ou même lorsqu'il modifie la sélection. Dans le système Android View, cet état est interne à EditText et exposé via les écouteurs onTextChanged. Cependant, étant donné que le composable est conçu pour un flux de données unidirectionnel, cela ne conviendrait pas.

Dans Compose, TextField est un composable sans état. Tout comme le TodoScreen qui affiche la liste de tâches changeante, un élément TextField affiche uniquement ce que vous lui dites et génère des événements lorsque l'utilisateur saisit du texte.

Créer un composable TextField avec état

Pour explorer le concept d'état dans Compose, nous allons créer un composant avec état pour l'affichage d'un élément TextField modifiable.

Pour commencer, ouvrez TodoScreen.kt et ajoutez la fonction suivante :

TodoScreen.kt

import androidx.compose.runtime.mutableStateOf

@Composable
fun TodoInputTextField(modifier: Modifier) {
   val (text, setText) = remember { mutableStateOf("") }
   TodoInputText(text, setText, modifier)
}

Cette fonction utilise remember pour s'ajouter de la mémoire à elle-même, puis stocke un élément mutableStateOf afin de créer un élément MutableState<String> qui est un type intégré de Compose fournissant un conteneur d'état observable.

Comme nous allons transmettre immédiatement une valeur et un événement setter à TodoInputText, nous décomposons l'objet MutableState en un getter et un setter.

Et voilà, nous avons créé un état interne dans TodoInputTextField.

Pour le voir en action, définissez un autre composable TodoItemInput qui affiche TodoInputTextField et un Button.

TodoScreen.kt

import androidx.compose.ui.Alignment

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   // onItemComplete is an event will fire when an item is completed by the user
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputTextField(Modifier
               .weight(1f)
               .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = { /* todo */ },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically)
           )
       }
   }
}

L'élément TodoItemInput ne comporte qu'un seul paramètre : un événement onItemComplete. Lorsque l'utilisateur termine une tâche TodoItem, l'événement est déclenché. Ce système de transmission d'un lambda est la principale méthode pour définir des événements personnalisés dans Compose.

Modifiez également le composable TodoScreen pour appeler TodoItemInput dans le TodoItemInputBackground en arrière-plan, déjà défini dans le projet :

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   Column {
       // add TodoItemInputBackground and TodoItem at the top of TodoScreen
       TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
           TodoItemInput(onItemComplete = onAddItem)
       }
...

Essayer TodoItemInput

Comme nous venons de définir un composable d'UI important pour le fichier, il est judicieux de lui ajouter un @Preview. Cela nous permettra d'explorer ce composable de façon isolée. Les lecteurs de ce fichier pourront aussi le prévisualiser rapidement.

Dans TodoScreen.kt, ajoutez une nouvelle fonction d'aperçu en bas de l'écran :

TodoScreen.kt

@Preview
@Composable
fun PreviewTodoItemInput() = TodoItemInput(onItemComplete = { })

Vous pouvez maintenant l'exécuter soit dans l'aperçu interactif, soit dans un émulateur pour déboguer ce composable de façon isolée.

Vous verrez alors qu'il affiche bien un champ de texte modifiable permettant à l'utilisateur de modifier du texte. Chaque fois qu'un caractère est saisi, l'état est mis à jour, ce qui déclenche la recomposition du champ TextField affiché pour l'utilisateur.

Affichage de PreviewTodoItemInput s&#39;exécutant avec un état interactif.

Cliquer sur le bouton pour ajouter un élément

Nous souhaitons maintenant que le bouton "Add" (Ajouter) crée bien un élément TodoItem. Pour ce faire, nous devons accéder à l'élément text à partir de TodoInputTextField.

Si vous examinez une partie de l'arborescence de composition de TodoItemInput, vous constatez que nous enregistrons l'état du texte dans TodoInputTextField.

Arborescence de composition TodoItemInput (composables intégrés masqués)

Arborescence : TodoItemInput avec des éléments TodoInputTextField et TodoEditButton enfants.  Le texte d&#39;état est un enfant de TodoInputTextField.

Cette structure ne nous permet pas de connecter l'élément onClick, car onClick doit accéder à la valeur actuelle de text. Nous souhaitons présenter l'état de l'élément text à TodoItemInput tout en utilisant un flux de données unidirectionnel.

Le système du flux de données unidirectionnel s'applique à la fois à l'architecture générale et à la conception d'un composable unique lorsqu'on utilise Jetpack Compose. Ici, nous souhaitons faire en sorte que les événements remontent et que l'état descende systématiquement.

Il nous faut donc faire descendre l'état de TodoItemInput et assurer une circulation vers le haut des événements.

Schéma du flux de données unidirectionnel pour TodoItemInput

Schéma : TodoItemInput en haut, état descendant vers TodoInputTextField, Événements montant de TodoInputTextField à TodoItemInput.

Pour ce faire, nous devons déplacer l'état du composable enfant, TodoInputTextField, vers l'élément parent TodoItemInput.

Arborescence de composition TodoItemInput avec hissage d'état (composables intégrés masqués)

e2ccddf8af39d228.png

Ce système est appelé hissage d'état. Nous allons "hisser" (ou "remonter") l'état d'un composable afin de rendre ce dernier sans état. Le hissage d'état est le principal système utilisé pour créer des flux de données unidirectionnels dans Compose.

Pour démarrer le hissage d'état, vous pouvez refactoriser tout état interne T d'un composable afin d'obtenir une paire de paramètres (value: T, onValueChange: (T) -> Unit).

Modifiez TodoInputTextField pour hisser l'état en ajoutant des paramètres (value, onValueChange) :

TodoScreen.kt

// TodoInputTextField with hoisted state

@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
   TodoInputText(text, onTextChange, modifier)
}

Ce code ajoute les paramètres value et onValueChange à TodoInputTextField. Le paramètre de valeur est text et le paramètre onValueChange est onTextChange.

L'état étant hissé, nous supprimons l'état mémorisé de TodoInputTextField.

L'état hissé selon cette méthode présente plusieurs propriétés importantes :

  • Référence unique : en déplaçant l'état au lieu de le dupliquer, nous conservons une source de référence unique pour le texte. Cela contribue à éviter les bugs.
  • Encapsulation : seul l'élément TodoItemInput peut modifier l'état, tandis que les autres composants peuvent envoyer des événements à TodoItemInput. En effectuant le hissage de cette manière, nous obtenons un seul composable avec état, même si plusieurs composables utilisent cet état.
  • Possibilité de partage : un état ainsi hissé peut être partagé comme une valeur immuable avec plusieurs composables. Ici, nous allons utiliser l'état à la fois dans TodoInputTextField et dans TodoEditButton.
  • Possibilité d'interception : l'élément TodoItemInput peut ignorer ou modifier des événements avant de modifier son état. Par exemple, TodoItemInput pourrait mettre en forme :emoji-codes: en emoji lorsque l'utilisateur saisit du texte.
  • Dissociation : l'état de TodoInputTextField peut être stocké n'importe où. Par exemple, nous pouvons choisir de sauvegarder cet état dans une base de données Room actualisée chaque fois qu'un caractère est saisi sans modifier TodoInputTextField.

Ajoutez maintenant l'état dans TodoItemInput et transmettez-le à TodoInputTextField :

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputTextField(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = { /* todo */ },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically)
           )
       }
   }
}

Nous avons maintenant hissé l'état et utilisons la valeur actuelle du texte pour déterminer le comportement du bouton TodoEditButton. Finalisez le rappel et activez (enable) le bouton uniquement lorsque le texte n'est pas vide, conformément à la conception :

TodoScreen.kt

// edit TodoItemInput
TodoEditButton(
   onClick = {
       onItemComplete(TodoItem(text)) // send onItemComplete event up
       setText("") // clear the internal text
   },
   text = "Add",
   modifier = Modifier.align(Alignment.CenterVertically),
   enabled = text.isNotBlank() // enable if text is not blank
)

Nous utilisons la même variable d'état, text, dans deux composables différents. Le hissage de l'état nous permet de partager celui-ci comme nous le faisons dans cet exemple. Nous y sommes parvenus tout en faisant uniquement de TodoItemInput un composable avec état.

Nouvelle exécution

Exécutez de nouveau l'application. Vous pouvez à présent ajouter des tâches. Félicitations ! Vous venez d'apprendre comment ajouter un état à un composable et comment hisser cet état.

767719165c35039e.png

Nettoyage du code

Avant de poursuivre, intégrez le champ TodoInputTextField. Nous venons de l'ajouter dans cette section pour comprendre comment fonctionne le hissage d'état. En examinant le code de TodoInputText fourni avec l'atelier de programmation, vous constaterez qu'il hisse déjà l'état en suivant les méthodes décrites dans cette section.

Lorsque vous avez terminé, votre élément TodoItemInput doit se présenter comme suit :

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputText(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = {
                   onItemComplete(TodoItem(text))
                   setText("")
               },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
   }
}

Dans la section suivante, nous allons poursuivre la création de ce projet et ajouter les icônes. Vous allez vous servir des outils présentés dans cette section pour hisser l'état et créer des UI interactives avec un flux de données unidirectionnel.

7. UI dynamique basée sur l'état

Dans la section précédente, vous avez appris comment ajouter un état à un composable et comment utiliser le hissage d'état pour rendre sans état un composable utilisant un état.

Nous allons maintenant découvrir comment créer une UI dynamique basée sur l'état. Pour revenir à la maquette du concepteur, nous devons afficher la ligne des icônes chaque fois que le texte n'est pas vide.

Entrée d'une tâche (état : développée – texte non vide) 721446d6a55fcaba.png

Entrée d'une tâche (état : réduite – texte vide) 6f46071227df3625.png

Déduire la valeur iconsVisible de l'état

Ouvrez TodoScreen.kt, puis créez une variable d'état pour contenir l'icône (icon) sélectionnée actuellement et une nouvelle valeur val pour iconsVisible renvoyant "True" chaque fois que le texte n'est pas vide.

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
    // ...

Nous avons ajouté un deuxième élément d'état, icon, qui contient l'icône actuellement sélectionnée.

La valeur iconsVisible n'ajoute pas d'état à TodoItemInput. TodoItemInput ne peut pas le modifier directement. Il est entièrement basé sur la valeur de text. Quelle que soit la valeur de text dans cette recomposition, iconsVisible sera défini en conséquence et nous pourrons l'utiliser pour afficher l'UI souhaitée.

Nous pourrions ajouter un état supplémentaire à TodoItemInput pour contrôler les cas où les icônes sont visibles, mais si vous examinez attentivement la spécification, la visibilité est entièrement basée sur le texte saisi. Si nous avions défini deux états, ils risqueraient d'évoluer de façon indépendante.

Nous préférons disposer d'une référence unique (Single Source of Truth). Dans ce composable, il suffit de définir text sur un état, et iconsVisible peut être basé sur text.

Poursuivez la modification de TodoItemInput afin d'afficher AnimatedIconRow en fonction de la valeur de iconsVisible. Si iconsVisible est défini sur "True", affichons un élément AnimatedIconRow, dans le cas contraire, affichons un espace avec 16.dp.

TodoScreen.kt

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   Column {
       Row( /* ... */ ) {
           /* ... */
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

Si vous exécutez à nouveau l'application maintenant, vous verrez que les icônes s'animent lorsque vous saisissez du texte.

Ici, nous modifions de façon dynamique l'arborescence de composition en fonction de la valeur de iconsVisible. Voici un diagramme de l'arborescence de composition pour les deux états.

Ce type de logique d'affichage conditionnel équivaut à la valeur GONE pour la visibilité dans le système Android View.

Arborescence de composition de la fonction TodoItemInput lorsque la valeur d'iconVisible change

ceb75cf0f13a1590.png

Si vous exécutez à nouveau l'application, la ligne de l'icône s'affiche correctement. Cependant, si vous cliquez sur "Add" (Ajouter), l'icône ne s'affiche pas dans la ligne de tâche ajoutée. En effet, nous n'avons pas actualisé notre événement afin qu'il transmette le nouvel état de l'icône. Voyons comment procéder pour ce faire.

Actualiser l'événement pour utiliser l'icône

Modifiez TodoEditButton dans TodoItemInput pour utiliser le nouvel état de l'élément icon dans l'écouteur onClick.

TodoScreen.kt

TodoEditButton(
   onClick = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   },
   text = "Add",
   modifier = Modifier.align(Alignment.CenterVertically),
   enabled = text.isNotBlank()
)

Vous pouvez utiliser le nouvel état icon directement dans l'écouteur onClick. Nous le réinitialisons à sa valeur par défaut lorsque l'utilisateur saisit une tâche TodoItem.

Si vous exécutez l'application maintenant, elle affiche une tâche interactive avec des boutons animés. Bravo !

3d8320f055510332.gif

Terminer la conception avec imeAction

Lorsque vous montrez l'application au concepteur, il vous indique qu'elle doit envoyer la tâche à réaliser à l'aide de l'action IME du clavier. Cela correspond au bouton bleu situé en bas à droite :

Clavier Android avec ImeAction.Done

6ee2444445ec12be.png

TodoInputText vous permet de répondre à imeAction avec son événement onImeAction.

Nous tenons à ce que onImeAction se comporte exactement de la même façon que TodoEditButton. Nous pourrions dupliquer le code, mais il serait difficile de le gérer dans la durée, car on risquerait de n'actualiser qu'un seul des événements.

Extrayons l'événement dans une variable. Cela nous permettra de l'utiliser à la fois pour le onImeAction de TodoInputText et pour le onClick de TodoEditButton.

Modifiez à nouveau TodoItemInput pour déclarer une nouvelle fonction lambda submit qui gère l'utilisateur effectuant une action d'envoi. Transmettez ensuite la fonction lambda nouvellement définie à TodoInputText et à TodoEditButton.

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   val submit = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputText(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               onImeAction = submit // pass the submit callback to TodoInputText
           )
           TodoEditButton(
               onClick = submit, // pass the submit callback to TodoEditButton
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

Si vous le souhaitez, vous pouvez extraire la logique de cette fonction. Toutefois, ce composable fonctionne de manière satisfaisante. Nous allons donc en rester là.

C'est l'un des principaux avantages de Compose. Comme vous déclarez votre UI dans Kotlin, vous pouvez créer toutes les abstractions nécessaires pour rendre le code découplé et réutilisable.

Pour gérer les actions avec le clavier, TextField fournit deux paramètres :

  • keyboardOptions : pour activer l'affichage de l'action IME "Done".
  • keyboardActions : pour spécifier l'action à déclencher en réponse à des actions IME spécifiques. Dans ce cas, lorsque l'utilisateur appuie sur "DONE", nous souhaitons appeler submit et masquer le clavier.

Pour contrôler le clavier virtuel, nous allons utiliser LocalSoftwareKeyboardController.current. Comme il s'agit d'une API expérimentale, nous devons annoter la fonction avec @OptIn(ExperimentalComposeUiApi::class).

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputText(
    text: String,
    onTextChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    onImeAction: () -> Unit = {}
) {
    val keyboardController = LocalSoftwareKeyboardController.current
    TextField(
        value = text,
        onValueChange = onTextChange,
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
        maxLines = 1,
        keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(onDone = {
            onImeAction()
            keyboardController?.hide()
        }),
        modifier = modifier
    )
}

Exécutez à nouveau l'application pour essayer les nouvelles icônes.

Exécutez de nouveau l'application. Vous verrez que les icônes s'affichent et se masquent automatiquement lorsque le texte change d'état. Vous pouvez également modifier la sélection d'icônes. Lorsque vous appuyez sur le bouton "Add" (Ajouter), une nouvelle tâche TodoItem est générée en fonction des valeurs saisies.

Félicitations, vous avez appris comment l'état fonctionne dans Compose, comment hisser un état et comment créer des UI dynamiques basées sur l'état.

Dans les sections suivantes, nous verrons comment s'y prendre pour créer des composants réutilisables qui interagissent avec l'état.

8. Extraire des composables sans état

Cette fois, le concepteur a opté pour une nouvelle approche. Oubliés le design déjanté et l'approche post-Material Design. Sa vision se définit aujourd'hui comme "interactive néo-moderne". Vous lui avez demandé ce que cela signifie. Sa réponse, où il était question d'emoji, n'était pas très claire. Quoi qu'il en soit, voici les maquettes.

Maquette pour le mode Édition

Le mode Édition réutilise la même UI que le mode de saisie, mais intègre l&#39;éditeur dans la liste.

Le concepteur indique qu'il réutilise la même UI pour les entrées, avec un remplacement des boutons par des emoji "enregistrer" et "fait".

À la fin de la section précédente, TodoItemInput était un composable avec état. Ce n'était pas un problème pour ajouter des tâches, mais comme il s'agit maintenant d'un éditeur, il devra être compatible avec le hissage d'état.

Dans cette section, vous allez apprendre à extraire l'état d'un composable avec état pour le rendre sans état. Nous pourrons ainsi réutiliser le même composable pour ajouter des tâches et les modifier.

Convertir TodoItemInput en composable sans état

Pour commencer, nous devons hisser l'état de TodoItemInput. Où pouvons-nous le stocker ? Nous pourrions l'intégrer directement dans TodoScreen, mais cet élément fonctionne déjà correctement avec un état interne et un événement terminé. Il n'y a aucune raison de changer cette API.

Divisons plutôt le composable en deux : un composable avec état, l'autre sans état.

Ouvrez TodoScreen.kt et divisez TodoItemInput en deux composables, puis renommez le composable avec état en TodoItemEntryInput, car il ne servira qu'à saisir de nouvelles tâches TodoItems.

TodoScreen.kt

@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   val submit = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   }
   TodoItemInput(
       text = text,
       onTextChange = setText,
       icon = icon,
       onIconChange = setIcon,
       submit = submit,
       iconsVisible = iconsVisible
   )
}

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean
) {
   Column {
       Row(
           Modifier
               .padding(horizontal = 16.dp)
               .padding(top = 16.dp)
       ) {
           TodoInputText(
               text,
               onTextChange,
               Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               submit
           )
           TodoEditButton(
               onClick = submit,
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

Cette transformation est très importante à comprendre lors de l'utilisation de Compose. Nous avons créé un composable avec état TodoItemInput et l'avons divisé en deux composables. Un avec état (TodoItemEntryInput) et un sans état (TodoItemInput).

Le composable sans état contient tout le code associé à l'UI, et le composable avec état n'a aucun code lié à l'UI. Cela nous permet de rendre le code de l'UI réutilisable dans les situations où il serait nécessaire de sauvegarder l'état d'une autre manière.

Exécuter à nouveau l'application

Exécutez à nouveau l'application pour vérifier que la saisie de la liste de tâches fonctionne toujours.

Félicitations, vous avez réussi à extraire un composable sans état d'un composable avec état sans modifier son API.

Dans la section suivante, nous verrons comment cela nous permet de réutiliser la logique de l'UI dans différents emplacements sans associer l'UI à l'état.

9. Utiliser l'état dans ViewModel

Pour finaliser la maquette interactive néo-moderne de notre concepteur, nous allons ajouter un état représentant la tâche en cours de modification.

Maquette pour le mode Édition

Le mode Édition réutilise la même UI que le mode de saisie, mais intègre l&#39;éditeur dans la liste.

Nous devons maintenant choisir où ajouter l'état pour cet éditeur. Nous pourrions créer un autre composable "TodoRowOrInlineEditor" avec état pour la gestion de l'affichage ou de la modification d'un élément, mais nous ne voulons afficher qu'un éditeur à la fois. Si l'on examine la maquette, on constate que la partie supérieure change également en mode Édition. Il va donc falloir hisser un état pour en permettre le partage.

Arborescence d'état pour TodoActivity

d32f2646a3f5ce65.png

Étant donné que TodoItemEntryInput et TodoInlineEditor doivent tous deux connaître l'état actuel de l'éditeur pour activer le masquage d'entrée en haut de l'écran, nous devons hisser l'état jusqu'au niveau de TodoScreen au minimum. L'écran est le composable de plus bas niveau dans la hiérarchie qui soit un parent commun à tous les composables ayant besoin de connaître l'état de l'éditeur.

Cependant, comme l'éditeur est dérivé de la liste et qu'il va la modifier, il est préférable de le conserver à proximité de celle-ci. Nous devons hisser l'état au niveau où il est susceptible d'être modifié. La liste se trouve dans TodoViewModel. C'est donc là que nous l'ajouterons.

Convertir TodoViewModel pour utiliser mutableStateListOf

Dans cette section, vous allez ajouter un état pour l'éditeur dans TodoViewModel. Dans la section suivante, vous l'utiliserez pour créer une fonctionnalité de modification directe.

En parallèle, nous essaierons d'utiliser mutableStateListOf dans un ViewModel afin de voir comment cela simplifie le code de l'état par rapport à LiveData<List> lors du ciblage de Compose.

mutableStateListOf permet de créer une instance observable de MutableList. Cela signifie que nous pouvons utiliser des éléments todoItems de la même manière qu'une liste MutableList, en éliminant la surcharge liée à l'utilisation de LiveData<List>.

Ouvrez TodoViewModel.kt et remplacez les éléments todoItems existants par une liste mutableStateListOf :

TodoViewModel.kt

import androidx.compose.runtime.mutableStateListOf

class TodoViewModel : ViewModel() {

   // remove the LiveData and replace it with a mutableStateListOf
   //private var _todoItems = MutableLiveData(listOf<TodoItem>())
   //val todoItems: LiveData<List<TodoItem>> = _todoItems

   // state: todoItems
   var todoItems = mutableStateListOf<TodoItem>()
    private set

   // event: addItem
   fun addItem(item: TodoItem) {
        todoItems.add(item)
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
       todoItems.remove(item)
   }
}

La déclaration de todoItems est courte et capture le même comportement que la version LiveData.

// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
    private set

En spécifiant private set, nous limitons les écritures à cet objet d'état à un setter privé visible uniquement dans ViewModel.

Actualiser TodoActivityScreen pour utiliser le nouveau ViewModel

Ouvrez TodoActivity.kt et actualisez TodoActivityScreen pour utiliser le nouveau ViewModel.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   TodoScreen(
       items = todoViewModel.todoItems,
       onAddItem = todoViewModel::addItem,
       onRemoveItem = todoViewModel::removeItem
   )
}

Exécutez de nouveau l'application. Vous constaterez qu'elle fonctionne avec le nouveau ViewModel. Vous avez modifié l'état pour utiliser mutableStateListOf. Voyons maintenant comment créer un état éditeur.

Définir un état éditeur

Nous allons maintenant ajouter l'état pour notre éditeur. Pour éviter de répéter le texte des tâches, nous allons modifier la liste directement. Pour ce faire, au lieu de conserver le texte que nous modifions, nous allons conserver un index de liste pour l'élément d'éditeur actuel.

Ouvrez TodoViewModel.kt et ajoutez un état éditeur.

Définissez un nouveau private var currentEditPosition qui contient la position de modification actuelle. Il contiendra l'index de la liste que nous sommes en train de modifier.

Ensuite, exposez le currentEditItem pour composer un message à l'aide d'un getter. Bien qu'il s'agisse d'une fonction Kotlin standard, currentEditPosition est observable pour Compose au même titre que l'élément State<TodoItem>.

TodoViewModel.kt

import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class TodoViewModel : ViewModel() {

   // private state
   private var currentEditPosition by mutableStateOf(-1)

    // state: todoItems
    var todoItems = mutableStateListOf<TodoItem>()
        private set

   // state
   val currentEditItem: TodoItem?
       get() = todoItems.getOrNull(currentEditPosition)

   // ..

Lorsqu'un composable appelle currentEditItem, il observe les modifications apportées à todoItems et à currentEditPosition. Si l'une de ces deux valeurs est modifiée, le composable peut appeler de nouveau la méthode getter pour obtenir la nouvelle valeur.

Définir des événements éditeur

Nous avons défini un état éditeur. Nous devons maintenant définir les événements que les composables peuvent appeler pour contrôler l'édition.

Créez trois événements : onEditItemSelected(item: TodoItem), onEditDone() et onEditItemChange(item: TodoItem).

Les événements onEditItemSelected et onEditDone modifient simplement l'élément currentEditPosition. Si vous modifiez currentEditPosition, Compose recomposera tout composable lisant currentEditItem.

TodoViewModel.kt

class TodoViewModel : ViewModel() {
   ...

   // event: onEditItemSelected
   fun onEditItemSelected(item: TodoItem) {
      currentEditPosition = todoItems.indexOf(item)
   }

   // event: onEditDone
   fun onEditDone() {
      currentEditPosition = -1
   }

   // event: onEditItemChange
   fun onEditItemChange(item: TodoItem) {
      val currentItem = requireNotNull(currentEditItem)
      require(currentItem.id == item.id) {
          "You can only change an item with the same id as currentEditItem"
      }

      todoItems[currentEditPosition] = item
   }
}

L'événement onEditItemChange actualise la liste au niveau indiqué par currentEditPosition. Ceci modifiera simultanément les valeurs renvoyées par currentEditItem et par todoItems. Avant cela, des contrôles de sécurité permettent de s'assurer que l'appelant n'essaie pas d'écrire un élément erroné.

Mettre fin à la modification lors de la suppression d'éléments

Actualisez l'événement removeItem pour fermer l'éditeur actuel lorsqu'un élément est supprimé.

TodoViewModel.kt

// event: removeItem
fun removeItem(item: TodoItem) {
   todoItems.remove(item)
   onEditDone() // don't keep the editor open when removing items
}

Exécuter à nouveau l'application

Et voilà ! Vous avez mis à jour ViewModel pour utiliser MutableState et constaté la façon dont cela simplifie le code d'état observable.

Dans la section suivante, nous allons ajouter un test pour ViewModel, puis passer à la création de l'UI d'édition.

Comme cette section comporte de nombreuses opérations, voici l'intégralité du modèle TodoViewModel après l'application de toutes les modifications :

TodoViewModel.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel

class TodoViewModel : ViewModel() {

    private var currentEditPosition by mutableStateOf(-1)

    var todoItems = mutableStateListOf<TodoItem>()
        private set

    val currentEditItem: TodoItem?
        get() = todoItems.getOrNull(currentEditPosition)

    fun addItem(item: TodoItem) {
        todoItems.add(item)
    }

    fun removeItem(item: TodoItem) {
        todoItems.remove(item)
        onEditDone() // don't keep the editor open when removing items
    }

    fun onEditItemSelected(item: TodoItem) {
        currentEditPosition = todoItems.indexOf(item)
    }

    fun onEditDone() {
        currentEditPosition = -1
    }

    fun onEditItemChange(item: TodoItem) {
        val currentItem = requireNotNull(currentEditItem)
        require(currentItem.id == item.id) {
            "You can only change an item with the same id as currentEditItem"
        }

        todoItems[currentEditPosition] = item
    }
}

10. Tester l'état dans ViewModel

Il est recommandé de tester votre ViewModel pour vous assurer que la logique de votre application est correcte. Dans cette section, nous allons écrire un programme pour montrer comment tester un modèle View en utilisant State<T> en guise d'état.

Ajouter un test à TodoViewModelTest

Ouvrez TodoViewModelTest.kt dans le répertoire test/, puis ajoutez un test pour supprimer un élément :

TodoViewModelTest.kt

import com.example.statecodelab.util.generateRandomTodoItem
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class TodoViewModelTest {

   @Test
   fun whenRemovingItem_updatesList() {
       // before
       val viewModel = TodoViewModel()
       val item1 = generateRandomTodoItem()
       val item2 = generateRandomTodoItem()
       viewModel.addItem(item1)
       viewModel.addItem(item2)

       // during
       viewModel.removeItem(item1)

       // after
       assertThat(viewModel.todoItems).isEqualTo(listOf(item2))
   }
}

Ce programme montre comment tester State<T>, qui est modifié directement par des événements. Dans la section précédente, il crée un nouveau modèle ViewModel, puis ajoute deux tâches à todoItems.

La méthode que nous testons est removeItem. Elle supprime le premier élément de la liste.

Enfin, nous utilisons des assertions de vérité pour confirmer que la liste ne contient que le second élément.

Nous n'avons aucune opération supplémentaire à effectuer pour lire todoItems dans un test si les actualisations ont été causées directement par le test (comme c'est le cas ici en appelant removeItem). Il s'agit simplement d'une tâche List<TodoItem>.

Les autres tests pour ce ViewModel suivent la même logique. Nous ne les aborderons donc pas dans cet atelier de programmation. Vous pouvez ajouter des tests du ViewModel pour vérifier qu'il fonctionne, ou ouvrir TodoViewModelTest dans le module terminé pour accéder à d'autres tests.

Dans la section suivante, nous ajouterons le nouveau mode Édition à l'UI.

11. Réutiliser des composables sans état

Nous sommes enfin prêts à implémenter notre conception interactive néo-moderne. Pour rappel, voici ce que nous essayons de créer :

Maquette pour le mode Édition

Le mode Édition réutilise la même UI que le mode de saisie, mais intègre l&#39;éditeur dans la liste.

Transmettre l'état et les événements à TodoScreen

Nous avons défini tous les états et événements dont nous aurons besoin pour cet écran dans TodoViewModel. Nous allons maintenant actualiser TodoScreen pour prendre en compte l'état et les événements nécessaires à son affichage.

Ouvrez TodoScreen.kt, puis modifiez la signature de TodoScreen pour ajouter :

  • L'élément en cours de modification : currentlyEditing: TodoItem?
  • Les trois nouveaux événements :

onStartEdit: (TodoItem) -> Unit, onEditItemChange: (TodoItem) -> Unit et onEditDone: () -> Unit

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   currentlyEditing: TodoItem?,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit,
   onStartEdit: (TodoItem) -> Unit,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit
) {
   // ...
}

Ces lignes correspondent simplement au nouvel état et au nouvel événement que nous venons de définir sur ViewModel.

Ensuite, dans TodoActivity.kt, transmettez les nouvelles valeurs dans TodoActivityScreen.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   TodoScreen(
       items = todoViewModel.todoItems,
       currentlyEditing = todoViewModel.currentEditItem,
       onAddItem = todoViewModel::addItem,
       onRemoveItem = todoViewModel::removeItem,
       onStartEdit = todoViewModel::onEditItemSelected,
       onEditItemChange = todoViewModel::onEditItemChange,
       onEditDone = todoViewModel::onEditDone
   )
}

Cette opération transmet simplement l'état et les événements requis par notre nouveau TodoScreen.

Définir un composable pour la fonctionnalité de modification directe

Créez un composable dans TodoScreen.kt qui utilise un composable sans état TodoItemInput pour définir une fonctionnalité de modification directe.

TodoScreen.kt

@Composable
fun TodoItemInlineEditor(
   item: TodoItem,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit,
   onRemoveItem: () -> Unit
) = TodoItemInput(
   text = item.task,
   onTextChange = { onEditItemChange(item.copy(task = it)) },
   icon = item.icon,
   onIconChange = { onEditItemChange(item.copy(icon = it)) },
   submit = onEditDone,
   iconsVisible = true
)

Ce composable est sans état. Il n'affiche que l'élément item transmis et utilise les événements pour demander l'actualisation de l'état. Comme nous avons déjà extrait un composable sans état TodoItemInput précédemment, nous pouvons facilement l'utiliser dans ce contexte sans état.

Cet exemple montre le potentiel de réutilisation des composables sans état. Même si l'en-tête utilise un TodoItemEntryInput avec état sur le même écran, nous pouvons hisser l'état jusqu'à ViewModel pour la fonctionnalité de modification directe.

Utiliser la fonctionnalité de modification directe dans LazyColumn

Dans la partie LazyColumn de TodoScreen, affichez TodoItemInlineEditor si l'élément actuel est en cours de modification. Sinon, affichez TodoRow.

De plus, démarrez le mode Édition en cliquant sur une tâche (au lieu de la supprimer comme avant).

TodoScreen.kt

// fun TodoScreen()
// ...
LazyColumn(
   modifier = Modifier.weight(1f),
   contentPadding = PaddingValues(top = 8.dp)
) {
 items(items) { todo ->
   if (currentlyEditing?.id == todo.id) {
       TodoItemInlineEditor(
           item = currentlyEditing,
           onEditItemChange = onEditItemChange,
           onEditDone = onEditDone,
           onRemoveItem = { onRemoveItem(todo) }
       )
   } else {
       TodoRow(
           todo,
           { onStartEdit(it) },
           Modifier.fillParentMaxWidth()
       )
   }
 }
}
// ...

Le composable LazyColumn est l'équivalent dans Compose d'un élément RecyclerView. Il recomposera uniquement les éléments de la liste nécessaires pour afficher l'écran actuel. Lorsque l'utilisateur le fait défiler, il élimine les composables qui ont quitté l'écran et en crée de nouveaux pour les éléments visibles.

Essayez le nouvel éditeur interactif !

Exécutez de nouveau l'application. Cette fois, lorsque vous cliquez sur une ligne de tâche, l'éditeur interactif s'ouvre.

Image montrant l&#39;application à cette étape dans l&#39;atelier de programmation

Nous utilisons le même composable d'UI sans état permettant d'afficher à la fois l'en-tête avec état et l'expérience d'éditeur interactive. Nous n'avons introduit aucun état en double pour cela.

Les choses commencent à prendre forme, mais le bouton d'ajout n'est pas correctement positionné et nous devons modifier l'en-tête. Terminons la conception en quelques étapes supplémentaires.

Remplacer l'en-tête lors de l'édition

Nous allons maintenant terminer la conception de l'en-tête, puis étudier comment remplacer le bouton par les emoji interactifs que le concepteur souhaite utiliser pour sa création interactive néo-moderne.

Revenez au composable TodoScreen, puis faites en sorte que l'en-tête réponde aux changements dans l'état éditeur. Si currentlyEditing a la valeur null, nous afficherons TodoItemEntryInput et transmettrons elevation = true à TodoItemInputBackground. Si la valeur de currentlyEditing n'est pas null, transmettez elevation = false à TodoItemInputBackground et affichez le texte "Editing item" (Modification de l'élément) dans le même arrière-plan.

TodoScreen.kt

import androidx.compose.material.MaterialTheme
import androidx.compose.ui.text.style.TextAlign

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   currentlyEditing: TodoItem?,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit,
   onStartEdit: (TodoItem) -> Unit,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit
) {
   Column {
       val enableTopSection = currentlyEditing == null
       TodoItemInputBackground(elevate = enableTopSection) {
           if (enableTopSection) {
               TodoItemEntryInput(onAddItem)
           } else {
               Text(
                   "Editing item",
                   style = MaterialTheme.typography.h6,
                   textAlign = TextAlign.Center,
                   modifier = Modifier
                       .align(Alignment.CenterVertically)
                       .padding(16.dp)
                       .fillMaxWidth()
               )
           }
       }
      // ..

Là encore, nous modifions l'arborescence de composition lors de la recomposition. Lorsque la partie supérieure est activée, la valeur TodoItemEntryInput est affichée. Sinon, un composable Text s'affiche avec la mention "Editing item" (Modification de l'élément).

L'arrière-plan TodoItemInputBackground qui était inclus dans le code de départ permet d'animer automatiquement le redimensionnement ainsi que les changements d'élévation. Ainsi, lorsque vous passez en mode Édition, ce code s'anime automatiquement entre les états.

Exécuter à nouveau l'application

99c4d82c8df52606.gif

Exécutez de nouveau l'application. Vous verrez qu'elle s'anime entre les états ou l'édition est activée ou désactivée. Nous avons presque terminé ce projet.

Dans la section suivante, nous allons apprendre à structurer le code des boutons emoji.

12. Utiliser des emplacements Slot pour transmettre des sections à l'écran

Les composables sans état qui affichent une UI complexe peuvent finir par contenir de nombreux paramètres. Si le nombre de paramètres n'est pas trop important et qu'ils configurent directement le composable, ce n'est pas un problème. Toutefois, il est parfois nécessaire de transmettre des paramètres pour configurer les enfants d'un composable.

Maquette de l&#39;application avec un bouton &quot;Add&quot; (Ajouter) dans la barre d&#39;outils et des boutons emoji dans l&#39;éditeur intégré

Dans cette approche interactive néo-moderne, le concepteur souhaite que le bouton "Add" (Ajouter) reste en haut de l'écran, mais soit remplacé par deux boutons emoji pour la fonctionnalité de modification directe. Nous pourrions ajouter des paramètres à TodoItemInput pour gérer ce cas, mais cette tâche ne semble pas relever des attributions de TodoItemInput.

Il nous faut trouver un moyen d'ajouter une section de boutons préconfigurée à un composable. L'appelant pourra ainsi configurer les boutons comme il le souhaite sans avoir à partager tous les états requis pour les configurer avec TodoItemInput.

Cela permettra à la fois de réduire le nombre de paramètres transmis au composable sans état et de les rendre plus réutilisables.

Le format à utiliser pour transmettre une section préconfigurée est un emplacement Slot. Les Slots sont des paramètres d'un composable qui permettent à l'appelant de décrire une section de l'écran. Vous trouverez des exemples d'emplacements Slot dans toutes les API de composables intégrées. Scaffold est l'un des exemples les plus couramment utilisés.

Scaffold est le composable qui permet de décrire un écran entier dans Material Design, par exemple les éléments topBar, bottomBar et le corps de l'écran.

Au lieu de fournir des centaines de paramètres pour configurer chaque section de l'écran, Scaffold segmente des emplacements Slot où vous pouvez placer les composables de votre choix. Cela réduit à la fois le nombre de paramètres nécessaires pour Scaffold et rend cet élément plus réutilisable. Si vous souhaitez créer un topBar personnalisé, Scaffold l'affichera sans problème.

@Composable
fun Scaffold(
   // ..
   topBar: @Composable (() -> Unit)? = null,
   bottomBar: @Composable (() -> Unit)? = null,
   // ..
   bodyContent: @Composable (PaddingValues) -> Unit
) {

Définir un emplacement Slot sur TodoItemInput

Ouvrez TodoScreen.kt, puis définissez un nouveau paramètre @Composable () -> Unit dans l'élément TodoItemInput sans état appelé buttonSlot.

TodoScreen.kt

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean,
   buttonSlot: @Composable () -> Unit
) {
  // ...

Il s'agit d'un emplacement générique où l'appelant peut placer les boutons de son choix. Nous l'utiliserons pour spécifier différents boutons pour l'en-tête et les instances de modification directe.

Afficher le contenu de buttonSlot

Remplacez l'appel de TodoEditButton par le contenu de l'emplacement Slot.

TodoScreen.kt

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean,
   buttonSlot: @Composable() () -> Unit,
) {
   Column {
       Row(
           Modifier
               .padding(horizontal = 16.dp)
               .padding(top = 16.dp)
       ) {
           TodoInputText(
               text,
               onTextChange,
               Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               submit
           )

           // New code: Replace the call to TodoEditButton with the content of the slot

           Spacer(modifier = Modifier.width(8.dp))
           Box(Modifier.align(Alignment.CenterVertically)) { buttonSlot() }

           // End new code
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

Nous pourrions appeler directement buttonSlot(), mais nous devons conserver align pour centrer tout ce que l'appelant pourra nous transmettre verticalement. Pour ce faire, nous plaçons l'emplacement Slot dans Box, qui est un composable de base.

Actualiser TodoItemEntryInput avec état pour utiliser l'emplacement Slot

Nous devons maintenant informer les appelants pour qu'ils utilisent buttonSlot. Mais modifions d'abord TodoItemEntryInput :

TodoScreen.kt

@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, onTextChange) = remember { mutableStateOf("") }
   val (icon, onIconChange) = remember { mutableStateOf(TodoIcon.Default)}

   val submit = {
        if (text.isNotBlank()) {
            onItemComplete(TodoItem(text, icon))
            onTextChange("")
            onIconChange(TodoIcon.Default)
        }
   }
   TodoItemInput(
       text = text,
       onTextChange = onTextChange,
       icon = icon,
       onIconChange = onIconChange,
       submit = submit,
       iconsVisible = text.isNotBlank()
   ) {
       TodoEditButton(onClick = submit, text = "Add", enabled = text.isNotBlank())
   }
}

Étant donné que buttonSlot est le dernier paramètre de TodoItemInput, nous pouvons utiliser une syntaxe lambda placée à la suite. Ensuite, il suffit d'appeler TodoEditButton dans le lambda, comme précédemment.

Actualiser TodoItemInlineEditor pour utiliser l'emplacement Slot

Pour terminer la refactorisation, modifiez TodoItemInlineEditor afin d'utiliser également l'emplacement Slot :

TodoScreen.kt

import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.TextButton

@Composable
fun TodoItemInlineEditor(
   item: TodoItem,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit,
   onRemoveItem: () -> Unit
) = TodoItemInput(
   text = item.task,
   onTextChange = { onEditItemChange(item.copy(task = it)) },
   icon = item.icon,
   onIconChange = { onEditItemChange(item.copy(icon = it)) },
   submit = onEditDone,
   iconsVisible = true,
   buttonSlot = {
       Row {
           val shrinkButtons = Modifier.widthIn(20.dp)
           TextButton(onClick = onEditDone, modifier = shrinkButtons) {
               Text(
                   text = "\uD83D\uDCBE", // floppy disk
                   textAlign = TextAlign.End,
                   modifier = Modifier.width(30.dp)
               )
           }
           TextButton(onClick = onRemoveItem, modifier = shrinkButtons) {
               Text(
                   text = "❌",
                   textAlign = TextAlign.End,
                   modifier = Modifier.width(30.dp)
               )
           }
       }
   }
)

Ici, nous transmettons buttonSlot en tant que paramètre nommé. Ensuite, dans buttonSlot, nous créons une ligne contenant les deux boutons pour la conception avec fonctionnalité de modification directe.

Exécuter à nouveau l'application

Exécutez à nouveau l'application et faites des essais d'utilisation de la fonctionnalité de modification directe.

ae3f79834a615ed0.gif

Dans cette section, nous avons personnalisé notre composable sans état à l'aide d'un emplacement Slot qui a permis à l'appelant de contrôler une section de l'écran. En utilisant des emplacements Slot, nous avons évité l'association de TodoItemInput avec les différentes conceptions susceptibles d'être ajoutées ultérieurement.

Lorsque vous ajoutez des paramètres à des composables sans état pour personnaliser les enfants, demandez-vous s'il ne vaudrait pas mieux utiliser des emplacements Slot. Ces emplacements ont tendance à rendre les composables plus réutilisables tout en conservant un nombre raisonnable de paramètres.

13. Félicitations

Félicitations, vous avez terminé cet atelier de programmation et appris à structurer l'état à l'aide d'un flux de données unidirectionnel dans une application Jetpack Compose.

Vous avez appris comment fonctionnent l'état et les événements afin d'extraire des composables sans état dans Compose. Vous avez également vu comment réutiliser un composable complexe dans différentes situations sur le même écran. Vous avez aussi appris à intégrer un élément ViewModel avec Compose à l'aide de LiveData et de MutableState.

Et maintenant ?

Consultez les autres ateliers de programmation du parcours Compose

Exemples d'applications

  • JetNews montre comment utiliser un flux de données unidirectionnel pour utiliser des composables avec état afin de gérer l'état dans un écran conçu avec des composables sans état

Documents de référence