Skip to content

4. Les composants

Le chapitre précédent a présenté les composants de [Material 3] et l'état. Ce chapitre montre comment écrire SES PROPRES composants : leurs paramètres, la façon dont ils préviennent leur parent, la composition, leur cycle de vie, les données qu'on leur fournit de loin, les comportements qu'on réutilise. Avec [Compose], un composant est une simple fonction [@Composable] : l'équivalent des widgets de [Flutter].

4.1. Exemple 10 : les composants et leurs paramètres

Une carte qui présente un médecin, utilisée plusieurs fois par un écran. Le type des données, partagé par le parent et l'enfant :

// le type des données partagé par le parent (PageMedecins) et l'enfant (CarteMedecin)
package fr.stahe.exemples.ex10

data class Medecin(
    val id: Int,
    val titre: String,
    val prenom: String,
    val nom: String,
    val specialite: String,
    val disponible: Boolean,
)

L'ENFANT, la carte :

// =========================================================================
// ex10/CarteMedecin.kt - un composant ENFANT
// -------------------------------------------------------------------------
// Il reçoit ses données de son parent par ses PARAMÈTRES. Leur type est
// vérifié à la compilation : CarteMedecin(medecin = 42, ...) serait refusé.
//   - un paramètre sans valeur par défaut : le parent DOIT le fournir ;
//   - compact: Boolean = false : un paramètre facultatif, avec sa valeur
//     par défaut ;
//   - modifier: Modifier = Modifier : la convention de Compose, le parent
//     peut ajuster la taille, les marges... de l'enfant.
//
// Règle d'or : un composant ne MODIFIE JAMAIS ses paramètres (ce sont des
// val). Les données descendent du parent vers l'enfant ; pour demander un
// changement, l'enfant appelle une fonction que le parent lui a passée
// (cf. exemple 11). Quand le parent change ses données, Compose RÉEXÉCUTE
// l'enfant avec de nouveaux paramètres.
// =========================================================================
package fr.stahe.exemples.ex10

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp

// le "style" de la carte : des constantes PRIVÉES à ce fichier. Aucun autre
// composant ne peut les voir, ni être gêné par elles
private val couleurInitiales = Color(0xFF087EA4)

@Composable
fun CarteMedecin(medecin: Medecin, rang: Int, modifier: Modifier = Modifier, compact: Boolean = false) {
    // une valeur calculée à partir des paramètres
    val initiales = "${medecin.prenom.first()}${medecin.nom.first()}"
    Card(
        modifier = modifier.padding(vertical = 4.dp),
        shape = RoundedCornerShape(12.dp),
        // une bordure verte pour un médecin disponible (null : pas de bordure)
        border = if (medecin.disponible) BorderStroke(2.dp, Color(0xFF4CAF50)) else null,
    ) {
        Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
            // un cercle coloré, avec les initiales au centre
            Box(
                contentAlignment = Alignment.Center,
                modifier = Modifier.size(40.dp).background(couleurInitiales, CircleShape),
            ) {
                Text(initiales, color = Color.White, fontWeight = FontWeight.Bold)
            }
            Spacer(Modifier.width(12.dp))
            // weight(1f) : le texte prend toute la largeur restante
            Column(Modifier.weight(1f)) {
                Text("$rang. ${medecin.titre} ${medecin.prenom} ${medecin.nom}", style = MaterialTheme.typography.titleMedium)
                if (!compact) {
                    Text(medecin.specialite, color = Color.Gray)
                }
            }
        }
    }
}
  • ligne 43 : une constante [private] : visible dans ce seul fichier ;
  • ligne 46 : les paramètres de la carte. Leur type est vérifié à la compilation ; [medecin] et [rang] sont obligatoires ; [compact] est facultatif ; [modifier: Modifier = Modifier] est la CONVENTION de [Compose] : le parent peut ajuster la taille, les marges... de l'enfant, qui applique ce [modifier] à son composant le plus extérieur ;
  • ligne 48 : une valeur calculée à partir des paramètres ;
  • lignes 49-53 : [Card], une carte [Material], avec une bordure verte pour un médecin disponible ([null] : pas de bordure) ;
  • lignes 57-61 : un cercle coloré ([CircleShape]) avec les initiales ;
  • lignes 67-68 : la spécialité n'est affichée qu'en mode normal.

La règle d'or : un composant ne MODIFIE JAMAIS ses paramètres (ce sont des [val], le compilateur l'interdit d'ailleurs). Les données descendent du parent vers l'enfant ; quand le parent change ses données, [Compose] RÉEXÉCUTE l'enfant avec ses nouveaux paramètres. Le PARENT :

// =========================================================================
// ex10/Ex10Activity.kt - le composant PARENT
// -------------------------------------------------------------------------
// Il utilise le composant CarteMedecin, autant de fois qu'il le faut, comme
// n'importe quel composant de Compose : une fonction @Composable est un
// morceau d'interface RÉUTILISABLE, avec ses propres paramètres et son
// style. Écrire ses propres composants, c'est le cœur du travail avec
// Compose : un écran bien écrit est fait de petits composants.
// =========================================================================
package fr.stahe.exemples.ex10

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples

class Ex10Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { PageMedecins() } }
    }
}

@Composable
fun PageMedecins() {
    var medecins by remember {
        mutableStateOf(
            listOf(
                Medecin(1, "Mme", "Marie", "PELISSIER", "généraliste", true),
                Medecin(2, "Mr", "Paul", "TATOU", "pédiatre", false),
                Medecin(3, "Melle", "Sophie", "DUPONT", "généraliste", true),
            ),
        )
    }
    var compact by remember { mutableStateOf(false) }

    PageExemple("Exemple 10 - les composants et leurs paramètres") {
        ListItem(
            headlineContent = { Text("affichage compact") },
            trailingContent = { Switch(checked = compact, onCheckedChange = { compact = it }) },
        )
        // le parent change SES données : Compose réexécute cette fonction, qui
        // rappelle les enfants avec leurs nouveaux paramètres
        OutlinedButton(onClick = { medecins = medecins.map { it.copy(disponible = !it.disponible) } }) {
            Text("Inverser les disponibilités")
        }
        Spacer(Modifier.height(8.dp))
        medecins.forEachIndexed { i, m ->
            // chaque carte reçoit SES paramètres ; key : son identité (cf. exemple 04)
            key(m.id) {
                CarteMedecin(medecin = m, rang = i + 1, compact = compact)
            }
        }
        Spacer(Modifier.height(16.dp))
        Text("La même carte, en version compacte, en fixant le paramètre :", color = Color.Gray)
        // le parent fixe aussi la LARGEUR de l'enfant, par le paramètre modifier
        CarteMedecin(medecin = medecins[0], rang = 1, compact = true, modifier = Modifier.width(260.dp))
    }
}
  • ligne 63 : le parent change SES données : une nouvelle liste, où chaque médecin est remplacé par une copie ;
  • lignes 67-70 : une carte par médecin, chacune avec SES paramètres ; [key(m.id)] : son identité (cf. exemple 04) ;
  • ligne 76 : le parent fixe aussi la LARGEUR de l'enfant, par le paramètre [modifier].

Image

Image

4.2. Exemple 11 : l'enfant prévient son parent

Les données descendent par les paramètres ; les informations REMONTENT par des fonctions que le parent passe à l'enfant (par convention, leur nom commence par [on]). Un premier enfant, un compteur, qui a son propre état et prévient son parent :

// =========================================================================
// ex11/Compteur.kt - un enfant qui PRÉVIENT son parent
// -------------------------------------------------------------------------
// Le parent lui passe des FONCTIONS en paramètres (par convention, leur
// nom commence par "on"), et l'enfant les appelle quand il se passe
// quelque chose :
//     Compteur(libelle = "Cafés", max = 3, onIncremente = { ... }, onLimite = { ... })
// Le type d'une fonction s'écrit (paramètres) -> résultat :
//   - (Int) -> Unit : une fonction qui reçoit un Int et ne renvoie rien ;
//   - () -> Unit    : une fonction sans paramètre, qui ne renvoie rien.
// =========================================================================
package fr.stahe.exemples.ex11

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun Compteur(libelle: String, max: Int, onIncremente: (Int) -> Unit, onLimite: () -> Unit) {
    // l'état PROPRE à ce compteur : chaque Compteur a le sien
    var valeur by remember { mutableIntStateOf(0) }

    fun plus() {
        if (valeur >= max) {
            onLimite()
            return
        }
        valeur++
        // on PRÉVIENT le parent, en lui transmettant la nouvelle valeur
        onIncremente(valeur)
    }

    Row(verticalAlignment = Alignment.CenterVertically) {
        Text(libelle, Modifier.width(110.dp))
        OutlinedButton(onClick = { plus() }) { Text("+1") }
        Spacer(Modifier.width(12.dp))
        SuggestionChip(onClick = {}, label = { Text("$valeur / $max") })
    }
}
  • ligne 30 : le type d'une fonction s'écrit [(paramètres) -> résultat] : [(Int) -> Unit] reçoit un [Int] et ne renvoie rien ; [() -> Unit] n'a pas de paramètre ;
  • ligne 32 : l'état PROPRE à ce compteur : chaque [Compteur] a le sien ;
  • lignes 34-41 : arrivé au maximum, le compteur prévient le parent ([onLimite()]) ; sinon, il s'incrémente et transmet sa nouvelle valeur ([onIncremente(valeur)]).

Un second enfant, un composant CONTRÔLÉ, qui s'utilise comme les champs de [Compose] : la valeur appartient au parent, qui la lui passe ([valeur]) et qu'il prévient de chaque frappe ([onChange]). Le composant n'a aucun état :

// =========================================================================
// ex11/ChampTexte.kt - un composant CONTRÔLÉ
// -------------------------------------------------------------------------
// Un champ de formulaire "maison" (libellé + champ + aide + compteur de
// caractères) qui s'utilise comme les champs de Compose :
//     ChampTexte(valeur = prenom, onChange = { prenom = it }, ...)
// La valeur appartient au PARENT, qui la lui passe (valeur) et qu'il
// prévient de chaque frappe (onChange). C'est le parent qui décide.
// Le composant n'a aucun état : il affiche ce qu'on lui donne. Si le
// parent change lui-même la valeur (bouton "Vider"), le champ suit, sans
// rien à écrire de plus.
// =========================================================================
package fr.stahe.exemples.ex11

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun ChampTexte(valeur: String, onChange: (String) -> Unit, libelle: String, max: Int, aide: String = "") {
    val plein = valeur.length == max
    OutlinedTextField(
        value = valeur,
        // le champ refuse les caractères de trop : la frappe n'est transmise
        // au parent que si le texte ne dépasse pas max caractères
        onValueChange = { texte -> if (texte.length <= max) onChange(texte) },
        label = { Text(libelle) },
        singleLine = true,
        // la bordure devient rouge quand le champ est plein
        isError = plein,
        supportingText = { Text(if (plein) "Longueur maximale atteinte - ${valeur.length}/$max" else "$aide - ${valeur.length}/$max") },
        modifier = Modifier.fillMaxWidth(),
    )
}
  • ligne 28 : la frappe n'est transmise au parent que si le texte ne dépasse pas [max] caractères : le champ « refuse » les caractères de trop ;
  • lignes 32-33 : la bordure devient rouge quand le champ est plein, et le texte d'aide le dit.

Le parent :

// =========================================================================
// ex11/Ex11Activity.kt - le parent écoute ses enfants
// -------------------------------------------------------------------------
// Les données descendent (paramètres) ; les informations remontent par des
// fonctions passées en paramètres :
//   1. des "événements" de l'enfant : onIncremente, onLimite ;
//   2. un composant contrôlé : valeur + onChange, comme un TextField.
//      L'état reste la propriété du parent, qui peut le modifier lui-même
//      (Vider).
// =========================================================================
package fr.stahe.exemples.ex11

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples

class Ex11Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { PageParent() } }
    }
}

@Composable
fun PageParent() {
    var total by remember { mutableIntStateOf(0) }
    var message by remember { mutableStateOf("") }
    var prenom by remember { mutableStateOf("Marie") }
    var nom by remember { mutableStateOf("PELISSIER") }

    // la fonction appelée par un compteur : elle reçoit la valeur qu'il transmet
    fun surIncremente(libelle: String, valeur: Int) {
        total++
        message = "$libelle vaut maintenant $valeur"
    }

    val styleTitre = MaterialTheme.typography.titleSmall
    PageExemple("Exemple 11 - l'enfant prévient son parent") {
        Card {
            Column(Modifier.padding(12.dp)) {
                Text("1. Des fonctions appelées par les enfants", style = styleTitre)
                Compteur(
                    libelle = "Cafés",
                    max = 3,
                    onIncremente = { v -> surIncremente("Cafés", v) },
                    onLimite = { message = "Pas plus de 3 cafés !" },
                )
                Compteur(
                    libelle = "Verres d'eau",
                    max = 8,
                    onIncremente = { v -> surIncremente("Verres d'eau", v) },
                    onLimite = { message = "Assez bu." },
                )
                Spacer(Modifier.height(8.dp))
                Text("Total des clics : $total - $message", fontStyle = FontStyle.Italic)
            }
        }
        Card {
            Column(Modifier.padding(12.dp)) {
                Text("2. Des composants contrôlés", style = styleTitre)
                ChampTexte(valeur = prenom, onChange = { prenom = it }, libelle = "Prénom", max = 20, aide = "Obligatoire")
                ChampTexte(valeur = nom, onChange = { nom = it }, libelle = "Nom", max = 20, aide = "Mis en majuscules à l'affichage")
                Spacer(Modifier.height(8.dp))
                Text(
                    buildAnnotatedString {
                        append("Données du parent : ")
                        withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append("$prenom ${nom.uppercase()}") }
                    },
                )
                Spacer(Modifier.height(8.dp))
                OutlinedButton(onClick = {
                    prenom = ""
                    nom = ""
                }) { Text("Vider (depuis le parent)") }
            }
        }
    }
}
  • lignes 57-59 : la fonction appelée par les compteurs ;
  • lignes 68-71 : chaque compteur reçoit deux lambdas ; la première reçoit la valeur transmise ([v]) et y ajoute le libellé du compteur ;
  • lignes 86-87 : les champs contrôlés : la valeur, et ce qu'il faut faire de chaque frappe ;
  • lignes 97-99 : le parent modifie lui-même ses données : les champs suivent, sans rien à écrire de plus.

Image

Image

À gauche, le maximum des cafés est atteint, et le nom fait 20 caractères ; à droite, après « Vider (depuis le parent) ».

4.3. Exemple 12 : la composition

Les paramètres d'un composant transmettent des DONNÉES... mais aussi du CONTENU : un paramètre peut être une fonction [@Composable], que le composant appelle où il veut. C'est ce que fait [Compose] partout ([Card], [Scaffold], le [label] d'un champ...). Un premier gabarit, une carte à trois emplacements :

// =========================================================================
// ex12/Carte.kt - un GABARIT réutilisable
// -------------------------------------------------------------------------
// Les paramètres d'un composant transmettent des DONNÉES... mais aussi du
// CONTENU : un paramètre peut être une fonction @Composable, que le
// composant appelle où il veut. Compose l'utilise partout : le content
// d'une Card, le topBar d'un Scaffold, le label d'un champ...
//   - contenu : par convention, le DERNIER paramètre, le contenu
//     "principal". On l'écrit après les parenthèses de l'appel :
//         Carte(couleur = ...) { Text("le contenu") }
//   - entete, pied : d'autres emplacements (des "slots"), fournis par le
//     parent ; l'en-tête a un contenu par défaut, le pied est facultatif.
// La carte fixe le cadre (bordure, en-tête, pied), le parent fournit ce
// qu'il y a dedans.
// =========================================================================
package fr.stahe.exemples.ex12

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@Composable
fun Carte(
    couleur: Color? = null, // "?" : facultatif (null par défaut)
    entete: @Composable () -> Unit = { Text("Sans titre") },
    pied: (@Composable () -> Unit)? = null,
    contenu: @Composable () -> Unit,
) {
    val fond = couleur ?: Color(0xFFE0E0E0) // "?:" : couleur, ou bien ce gris
    Card(
        modifier = Modifier.padding(vertical = 4.dp),
        shape = RoundedCornerShape(8.dp),
        border = BorderStroke(1.dp, fond),
    ) {
        Column {
            Box(Modifier.fillMaxWidth().background(fond).padding(10.dp)) {
                // ProvideTextStyle : le style de TOUS les textes de l'en-tête,
                // quel que soit le contenu fourni par le parent
                ProvideTextStyle(TextStyle(color = if (couleur == null) Color.Black else Color.White, fontWeight = FontWeight.Bold)) {
                    entete()
                }
            }
            Box(Modifier.padding(10.dp)) { contenu() }
            // pas de pied fourni : pas de pied du tout
            if (pied != null) {
                Box(Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(8.dp)) {
                    ProvideTextStyle(TextStyle(color = Color.Gray, fontSize = 12.sp)) { pied() }
                }
            }
        }
    }
}
  • lignes 38-41 : trois paramètres de contenu : [entete], avec un contenu par défaut ; [pied], FACULTATIF ([(@Composable () -> Unit)?] : une fonction, ou [null]) ; [contenu], le dernier, qu'on écrit après les parenthèses de l'appel ;
  • lignes 53-54 : [ProvideTextStyle] fixe le style de TOUS les textes de l'en-tête, quel que soit le contenu fourni par le parent ;
  • lignes 59-61 : pas de pied fourni, pas de pied du tout.

Un second gabarit, un tableau GÉNÉRIQUE : il sait parcourir les lignes, pas afficher UNE cellule : c'est le parent qui le lui dit, en décrivant les colonnes :

// =========================================================================
// ex12/Tableau.kt - un tableau GÉNÉRIQUE
// -------------------------------------------------------------------------
// Tableau<T> est un composant générique : il affiche des lignes de
// n'importe quel type T. Il sait parcourir les lignes, mais pas comment
// afficher UNE cellule : c'est le parent qui le lui dit, en décrivant ses
// colonnes. Chaque Colonne<T> a un titre, une largeur relative (poids) et
// une FONCTION @Composable, cellule(ligne, index), qui affiche sa cellule.
// Le contenu fourni par le parent utilise des données qui appartiennent à
// l'enfant (la ligne, son rang) : c'est le principe des listes de Compose
// (LazyColumn et sa fonction items { ... }).
// =========================================================================
package fr.stahe.exemples.ex12

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp

// une colonne d'un tableau de T
class Colonne<T>(val titre: String, val poids: Float = 1f, val cellule: @Composable (ligne: T, index: Int) -> Unit)

@Composable
fun <T> Tableau(
    lignes: List<T>,
    colonnes: List<Colonne<T>>,
    // affiché quand il n'y a aucune ligne
    vide: @Composable () -> Unit = { Text("Aucune donnée.") },
) {
    Column {
        // la ligne des titres
        Row {
            for (c in colonnes) {
                Text(c.titre, Modifier.weight(c.poids).padding(6.dp), fontWeight = FontWeight.Bold)
            }
        }
        HorizontalDivider()
        if (lignes.isEmpty()) {
            Box(Modifier.padding(6.dp)) { ProvideTextStyle(TextStyle(color = Color.Gray)) { vide() } }
        }
        lignes.forEachIndexed { i, ligne ->
            if (i > 0) HorizontalDivider(color = Color(0xFFE0E0E0))
            Row(verticalAlignment = Alignment.CenterVertically) {
                // les cellules de CETTE ligne, affichées par les fonctions du parent
                for (c in colonnes) {
                    Box(Modifier.weight(c.poids).padding(6.dp)) { c.cellule(ligne, i) }
                }
            }
        }
    }
}
  • ligne 31 : une colonne d'un tableau de [T] : un titre, une largeur relative, et une fonction [@Composable] qui affiche la cellule d'une ligne ;
  • lignes 55-56 : les cellules de chaque ligne, affichées par les fonctions du parent. Le contenu fourni par le parent utilise des données qui appartiennent à l'enfant (la ligne, son rang) : c'est le principe des listes de [Compose] ([LazyColumn], cf. l'étude de cas).

L'écran qui utilise les deux gabarits :

// =========================================================================
// ex12/Ex12Activity.kt - utiliser les gabarits Carte et Tableau
// =========================================================================
package fr.stahe.exemples.ex12

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material3.Icon
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples

// un créneau ; patient : null si le créneau est libre
data class Creneau(val id: Int, val horaire: String, val patient: String?)

val creneaux = listOf(
    Creneau(1, "8:00 - 8:20", null),
    Creneau(2, "8:20 - 8:40", "Mme Alice DURAND"),
    Creneau(3, "8:40 - 9:00", null),
)

data class Personne(val id: Int, val nom: String)

class Ex12Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { PageComposition() } }
    }
}

@Composable
fun PageComposition() {
    val libres = creneaux.count { it.patient == null }
    PageExemple("Exemple 12 - la composition") {
        // entete et pied : des fonctions @Composable passées en paramètres ;
        // le contenu : la lambda écrite APRÈS les parenthèses
        Carte(
            couleur = Color(0xFF3F51B5),
            entete = {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Icon(Icons.Default.DateRange, contentDescription = null, tint = Color.White)
                    Spacer(Modifier.width(8.dp))
                    Text("Agenda de Mme PELISSIER")
                }
            },
            pied = { Text("$libres créneau(x) libre(s)") },
        ) {
            // Tableau<Creneau> : T est déduit du type de lignes
            Tableau(
                lignes = creneaux,
                colonnes = listOf(
                    // c reçoit la ligne, i son rang
                    Colonne("#", 0.4f) { _, i -> Text("${i + 1}") },
                    Colonne("Créneau", 1.2f) { c, _ -> Text(c.horaire) },
                    Colonne("Statut", 1.6f) { c, _ ->
                        if (c.patient != null) {
                            SuggestionChip(onClick = {}, label = { Text(c.patient) })
                        } else {
                            SuggestionChip(
                                onClick = {},
                                label = { Text("Libre") },
                                colors = SuggestionChipDefaults.suggestionChipColors(containerColor = Color(0xFFC8E6C9)),
                            )
                        }
                    },
                ),
            )
        }
        Carte(couleur = Color(0xFF4CAF50), entete = { Text("Une carte sans pied") }) {
            Text("Le pied n'est pas fourni : la carte ne l'affiche pas.")
        }
        // pas d'en-tête fourni : le contenu par défaut, « Sans titre »
        Carte {
            Tableau(
                lignes = emptyList<Personne>(),
                colonnes = listOf(Colonne("Nom") { p, _ -> Text(p.nom) }),
                vide = { Text("Personne pour l'instant.") },
            )
        }
    }
}
  • lignes 52-61 : l'en-tête et le pied, des lambdas passées par leur nom ; le contenu, la lambda écrite APRÈS les parenthèses ;
  • lignes 64-70 : [Tableau<Creneau>] : [T] est DÉDUIT du type des lignes. Chaque colonne reçoit la ligne et son rang ; [_] : un paramètre inutilisé ;
  • lignes 88-92 : une carte sans en-tête (le contenu par défaut, « Sans titre ») et un tableau vide.

Image

4.4. Exemple 13 : le cycle de vie d'un composant

Un composant ENTRE dans la composition (sa fonction est exécutée pour la première fois), est RECOMPOSÉ autant de fois qu'il le faut, puis SORT de la composition, pour toujours. [Compose] fournit des « effets » pour agir à ces moments-là :

  • [LaunchedEffect(cle) [ ... ]] : lance une COROUTINE (du code qui peut attendre, cf. exemple 19) à l'entrée, la relance si [cle] change, l'ANNULE à la sortie ;
  • [DisposableEffect(cle) { ... ; onDispose [ ... ] }] : du code à l'entrée, et son nettoyage à la sortie.

Une horloge qui démarre une minuterie... et doit impérativement l'arrêter quand elle disparaît :

// =========================================================================
// ex13/Horloge.kt - un composant qui démarre et s'arrête
// -------------------------------------------------------------------------
// La vie d'un composant dans l'écran ("la composition") :
//   1. il ENTRE dans la composition : sa fonction est exécutée pour la
//      première fois. On y démarre ce qui doit l'être ;
//   2. il est RECOMPOSÉ autant de fois qu'il le faut (son état ou ses
//      paramètres ont changé) ;
//   3. il SORT de la composition : il disparaît de l'écran, pour toujours.
//      On y ARRÊTE ce qui a été démarré.
// Compose fournit des "effets" pour agir à ces moments-là, en dehors de
// l'affichage proprement dit :
//   - LaunchedEffect(cle) { ... } : lance une COROUTINE (du code qui peut
//     attendre, cf. exemple 19) à l'entrée dans la composition, la relance
//     si cle change, l'ANNULE à la sortie ;
//   - DisposableEffect(cle) { ...; onDispose { ... } } : du code à
//     l'entrée, et son nettoyage (onDispose) à la sortie.
// Une horloge lance une minuterie quand elle apparaît... et doit
// impérativement l'arrêter quand elle disparaît : avec LaunchedEffect,
// c'est automatique.
// =========================================================================
package fr.stahe.exemples.ex13

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import java.time.LocalTime
import java.time.format.DateTimeFormatter

fun heureCourante(): String = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))

@Composable
fun Horloge(onJournal: (String) -> Unit, couleur: Color = Color(0xFF3F51B5)) {
    var heure by remember { mutableStateOf(heureCourante()) }
    // la DERNIÈRE fonction onJournal reçue (les effets ci-dessous durent
    // plus longtemps qu'une exécution de la fonction)
    val journal by rememberUpdatedState(onJournal)

    // l'entrée dans la composition, et la sortie
    DisposableEffect(Unit) {
        journal("Horloge : entrée dans la composition - démarrage de la minuterie")
        onDispose { journal("Horloge : sortie de la composition - minuterie arrêtée") }
    }

    // la minuterie : une coroutine, ANNULÉE automatiquement à la sortie
    LaunchedEffect(Unit) {
        while (true) {
            delay(1000) // attendre une seconde, sans bloquer le téléphone
            heure = heureCourante()
        }
    }

    // un paramètre qui change : l'effet est relancé (sauf la première fois)
    var premiereCouleur by remember { mutableStateOf(true) }
    LaunchedEffect(couleur) {
        if (premiereCouleur) premiereCouleur = false else journal("Horloge : recomposée avec une nouvelle couleur")
    }

    Text(heure, fontSize = 40.sp, fontFamily = FontFamily.Monospace, color = couleur)
}
  • ligne 47 : [rememberUpdatedState] : les effets durent plus longtemps qu'une exécution de la fonction ; ils doivent appeler la DERNIÈRE fonction [onJournal] reçue, pas la première ;
  • lignes 50-52 : l'entrée dans la composition, et la sortie ([onDispose]) ;
  • lignes 57-59 : la minuterie, une boucle infinie... dans une coroutine : [delay(1000)] attend une seconde SANS bloquer le téléphone ; la coroutine est annulée à la sortie de la composition, et la boucle avec elle ;
  • ligne 65 : un effet dont la clé est un paramètre : il est relancé quand la couleur change.

L'écran :

// =========================================================================
// ex13/Ex13Activity.kt - le cycle de vie
// -------------------------------------------------------------------------
//   - if (avecHorloge) Horloge(...) : cocher / décocher la case fait
//     entrer / sortir l'horloge de la composition (cf. Horloge.kt) ;
//     changer sa couleur la recompose ;
//   - un champ qui prend le focus dès qu'il apparaît (le clavier du
//     téléphone s'ouvre) ;
//   - le titre de l'application suit la saisie (TitreTache) ;
//   - la vie de l'ACTIVITÉ elle-même (son "Lifecycle") : sur un téléphone,
//     elle passe au second plan (on ouvre une autre application, l'écran
//     s'éteint), puis revient : on peut y réagir (sauvegarder, arrêter une
//     minuterie...).
// =========================================================================
package fr.stahe.exemples.ex13

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import fr.stahe.exemples.commun.TitreTache

class Ex13Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { PageCycleDeVie() } }
    }
}

@Composable
fun PageCycleDeVie() {
    var avecHorloge by remember { mutableStateOf(true) }
    var rouge by remember { mutableStateOf(false) }
    val journal = remember { mutableStateListOf<String>() }
    var recherche by remember { mutableStateOf("") }
    var afficherRecherche by remember { mutableStateOf(false) }

    fun tracer(message: String) {
        journal.add("${heureCourante()} $message")
    }

    // la vie de l'activité : on s'ABONNE à ses changements d'état à l'entrée
    // dans la composition, on se DÉSABONNE à la sortie (onDispose)
    val cycleDeVie = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(cycleDeVie) {
        val observateur = LifecycleEventObserver { _, evenement -> tracer("Activité : ${evenement.name}") }
        cycleDeVie.addObserver(observateur)
        onDispose { cycleDeVie.removeObserver(observateur) }
    }

    TitreTache(if (recherche.isEmpty()) "Exemple 13" else "Recherche : $recherche")

    PageExemple("Exemple 13 - le cycle de vie") {
        Card {
            Column(Modifier.padding(12.dp)) {
                LigneCase("afficher l'horloge", avecHorloge) { avecHorloge = it }
                LigneCase("en rouge", rouge) { rouge = it }
                if (avecHorloge) {
                    Horloge(onJournal = { tracer(it) }, couleur = if (rouge) Color.Red else Color(0xFF3F51B5))
                }
            }
        }
        Card {
            Column(Modifier.padding(12.dp)) {
                if (!afficherRecherche) {
                    OutlinedButton(onClick = { afficherRecherche = true }) { Text("Rechercher...") }
                } else {
                    // un champ qui prend le focus dès son apparition
                    val focus = remember { FocusRequester() }
                    OutlinedTextField(
                        value = recherche,
                        onValueChange = { recherche = it },
                        placeholder = { Text("le titre suit la saisie") },
                        modifier = Modifier.fillMaxWidth().focusRequester(focus),
                    )
                    LaunchedEffect(Unit) { focus.requestFocus() }
                }
            }
        }
        Text("Journal", style = MaterialTheme.typography.titleSmall)
        journal.forEachIndexed { i, ligne -> Text("${i + 1}. $ligne", fontSize = 12.sp) }
    }
}

// une case à cocher et son libellé ; toute la ligne réagit à l'appui.
// onChange est le DERNIER paramètre : on peut l'écrire après les parenthèses
@Composable
fun LigneCase(libelle: String, coche: Boolean, onChange: (Boolean) -> Unit) {
    ListItem(
        modifier = Modifier.toggleable(value = coche, onValueChange = onChange),
        leadingContent = { Checkbox(checked = coche, onCheckedChange = null) },
        headlineContent = { Text(libelle) },
    )
}
  • lignes 74-78 : la vie de l'ACTIVITÉ elle-même : sur un téléphone, elle passe au second plan (une autre application, l'écran qui s'éteint) puis revient. On s'abonne à ses changements ([addObserver]) à l'entrée dans la composition, on se désabonne à la sortie ;
  • lignes 4-89 : [if (avecHorloge) Horloge(...)] : décocher la case fait SORTIR l'horloge de la composition ; la recocher en crée une NOUVELLE ;
  • lignes 99-106 : un champ qui prend le focus dès qu'il apparaît : [LaunchedEffect] s'exécute APRÈS l'affichage, quand le champ existe.

Image

Le journal montre la séquence : l'activité est créée, démarrée, reprise ([ON_CREATE], [ON_START], [ON_RESUME]) ; l'horloge entre dans la composition ; elle change de couleur ; puis elle est décochée et recochée.

4.5. Exemple 14 : les données fournies

Comment partager des données entre des composants éloignés, sans les passer de paramètre en paramètre à travers tous les intermédiaires ? Avec une [CompositionLocal], une donnée « locale à la composition » : on la DÉCLARE une fois ; un composant la FOURNIT à tous ses descendants ([CompositionLocalProvider]) ; n'importe quel descendant la LIT ([LocalXxx.current]). [Compose] l'utilise lui-même : [MaterialTheme.colorScheme], [LocalContext.current], [LocalDensity.current]... C'est l'équivalent des [InheritedWidget] et du paquet [provider] de [Flutter].

Un journal, fourni à tout l'écran :

// =========================================================================
// ex14/fournisseurs/Journal.kt - une donnée pour TOUTE l'application
// -------------------------------------------------------------------------
// Comment partager des données et des fonctions entre des composants
// éloignés, sans les passer de paramètre en paramètre à travers tous les
// intermédiaires ? Avec une CompositionLocal, une donnée "locale à la
// composition" :
//   1. on la DÉCLARE une fois : val LocalJournal = staticCompositionLocalOf<Journal> { ... } ;
//   2. un composant placé haut dans l'arbre la FOURNIT à tous ses
//      descendants : CompositionLocalProvider(LocalJournal provides journal) { ... } ;
//   3. n'importe quel descendant, à n'importe quelle profondeur, la LIT :
//      LocalJournal.current.
// C'est le mécanisme que Compose utilise lui-même : MaterialTheme.colorScheme,
// LocalContext.current, LocalDensity.current... sont des lectures de
// CompositionLocal.
//
// Le journal lui-même est un objet ordinaire, dont l'état (ses lignes) est
// OBSERVÉ par Compose (mutableStateListOf) : un composant qui lit ses
// lignes est recomposé quand elles changent.
// =========================================================================
package fr.stahe.exemples.ex14.fournisseurs

import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.staticCompositionLocalOf
import java.time.LocalTime
import java.time.format.DateTimeFormatter

class Journal {
    // la liste observée ; dehors, on ne la voit que comme une List NON modifiable
    private val _lignes = mutableStateListOf<String>()
    val lignes: List<String> get() = _lignes

    fun tracer(message: String) {
        _lignes.add(0, "${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))} $message")
        if (_lignes.size > 6) _lignes.removeAt(_lignes.lastIndex)
    }
}

// la déclaration : le type de la donnée, et ce qui se passe si personne ne
// la fournit (ici, une erreur explicite).
// static : le journal fourni ne change jamais (c'est son CONTENU qui change)
val LocalJournal = staticCompositionLocalOf<Journal> { error("LocalJournal : aucun journal fourni au-dessus de ce composant") }
  • lignes 30-31 : l'état du journal, une liste observée, qu'on n'expose au dehors que comme une [List] NON modifiable ;
  • ligne 8 : la déclaration : le type de la donnée, et ce qui se passe si personne ne la fournit (une erreur explicite). [static] : la donnée fournie ne change jamais (c'est son CONTENU qui change).

Un panier, fourni à une partie de l'écran par un composant fournisseur, qui le crée :

// =========================================================================
// ex14/fournisseurs/Panier.kt - une donnée fournie par UN composant
// -------------------------------------------------------------------------
// Le panier n'est fourni qu'à une partie de l'arbre : les composants
// qu'englobe FournisseurPanier (cf. Ex14Activity.kt). Deux fournisseurs
// placés à deux endroits de l'écran donneraient deux paniers indépendants.
// C'est le fournisseur qui CRÉE le panier (remember) ; il utilise
// lui-même le journal.
// =========================================================================
package fr.stahe.exemples.ex14.fournisseurs

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf

data class Article(val libelle: String, val prix: Double)

class Panier(private val journal: Journal) {
    private val _articles = mutableStateListOf<Article>()
    val articles: List<Article> get() = _articles

    // une valeur calculée : sumOf additionne une valeur de chaque article
    val total: Double get() = _articles.sumOf { it.prix }

    fun ajouter(article: Article) {
        _articles.add(article)
        journal.tracer("panier : + ${article.libelle}")
    }

    fun retirer(index: Int) {
        journal.tracer("panier : - ${_articles[index].libelle}")
        _articles.removeAt(index)
    }
}

val LocalPanier = staticCompositionLocalOf<Panier> { error("LocalPanier : aucun panier fourni au-dessus de ce composant") }

// le fournisseur : il crée le panier UNE fois, et le fournit à son contenu
@Composable
fun FournisseurPanier(contenu: @Composable () -> Unit) {
    val journal = LocalJournal.current
    val panier = remember { Panier(journal) }
    CompositionLocalProvider(LocalPanier provides panier, content = contenu)
}
  • ligne 25 : [total], une propriété calculée ; [sumOf] additionne une valeur de chaque article ;
  • lignes 42-45 : le fournisseur crée le panier UNE fois ([remember]), et le fournit à son contenu ; il lit lui-même le journal, fourni plus haut.

Une horloge, fournie avec une période paramétrable :

// =========================================================================
// ex14/fournisseurs/Horloge.kt - un fournisseur PARAMÉTRÉ
// -------------------------------------------------------------------------
// Une horloge, dont la période de rafraîchissement est un paramètre du
// fournisseur : FournisseurHorloge(periodeMs = 5000) { ... }.
// Deux fournisseurs imbriqués : un composant lit celui qui est le plus
// PROCHE au-dessus de lui.
// Cette fois, la donnée fournie CHANGE (l'heure, à chaque tic) :
// compositionLocalOf (et non staticCompositionLocalOf). Compose ne
// recompose alors que les composants qui LISENT l'heure.
// L'horloge s'arrête quand son fournisseur disparaît (LaunchedEffect).
// =========================================================================
package fr.stahe.exemples.ex14.fournisseurs

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import java.time.LocalDateTime

// la valeur par défaut : une date figée, lue par un composant sans fournisseur
val LocalHorloge = compositionLocalOf { LocalDateTime.of(2026, 1, 1, 0, 0) }

@Composable
fun FournisseurHorloge(periodeMs: Long = 1000, contenu: @Composable () -> Unit) {
    var maintenant by remember { mutableStateOf(LocalDateTime.now()) }
    // si la période change, la coroutine est annulée et relancée
    LaunchedEffect(periodeMs) {
        while (true) {
            delay(periodeMs)
            maintenant = LocalDateTime.now()
        }
    }
    CompositionLocalProvider(LocalHorloge provides maintenant, content = contenu)
}
  • ligne 27 : cette fois, la donnée fournie CHANGE (l'heure, à chaque tic) : [compositionLocalOf], et non [staticCompositionLocalOf]. [Compose] ne recompose alors que les composants qui LISENT l'heure ;
  • lignes 33-36 : si la période change, la coroutine est annulée et relancée.

Deux descendants qui utilisent le panier :

// ex14/Catalogue.kt - un descendant qui AJOUTE au panier
package fr.stahe.exemples.ex14

import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import fr.stahe.exemples.ex14.fournisseurs.Article
import fr.stahe.exemples.ex14.fournisseurs.LocalPanier

val catalogue = listOf(Article("Consultation", 30.0), Article("Vaccin", 12.5), Article("Certificat", 5.0))

@Composable
fun Catalogue() {
    // le panier fourni par PageBoutique (une erreur si personne ne le fournit)
    val panier = LocalPanier.current
    Card {
        Column {
            for (a in catalogue) {
                ListItem(
                    headlineContent = { Text("${a.libelle} (${a.prix} €)") },
                    trailingContent = {
                        IconButton(onClick = { panier.ajouter(a) }) {
                            Icon(Icons.Default.ShoppingCart, contentDescription = "ajouter ${a.libelle}")
                        }
                    },
                )
            }
        }
    }
}
// =========================================================================
// ex14/ResumePanier.kt
// -------------------------------------------------------------------------
// Un autre descendant : le MÊME panier que Catalogue, mais SA PROPRE
// horloge, rafraîchie toutes les 5 secondes. Un composant ne lit que les
// fournisseurs placés AU-DESSUS de lui : ResumePanier place donc un second
// FournisseurHorloge autour de son contenu, qu'il confie à un petit
// composant, Contenu, qui lit l'horloge la plus PROCHE : celle de 5 s.
// =========================================================================
package fr.stahe.exemples.ex14

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.ex14.fournisseurs.FournisseurHorloge
import fr.stahe.exemples.ex14.fournisseurs.LocalHorloge
import fr.stahe.exemples.ex14.fournisseurs.LocalPanier

@Composable
fun ResumePanier() {
    FournisseurHorloge(periodeMs = 5000) { Contenu() }
}

@Composable
private fun Contenu() {
    val panier = LocalPanier.current
    val maintenant = LocalHorloge.current // la plus proche : celle de 5 s
    Card {
        Column(Modifier.padding(12.dp)) {
            Text(
                "Panier (${panier.articles.size} article(s)) - ${maintenant.toLocalTime().withNano(0)}",
                style = MaterialTheme.typography.titleSmall,
            )
            panier.articles.forEachIndexed { i, a ->
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Text(a.libelle)
                    TextButton(onClick = { panier.retirer(i) }) { Text("retirer") }
                }
            }
            Text("Total : ${panier.total} €", fontWeight = FontWeight.Bold)
        }
    }
}
  • lignes 29-30 : [ResumePanier] place un SECOND fournisseur d'horloge, de 5 secondes, autour de son contenu : un composant lit le fournisseur le plus PROCHE au-dessus de lui.

L'écran :

// =========================================================================
// ex14/Ex14Activity.kt - trois niveaux de données fournies
// -------------------------------------------------------------------------
//   - le journal : fourni tout en haut, par l'activité, à tout l'écran ;
//   - le panier, fourni par PageBoutique : partagé par Catalogue et
//     ResumePanier, qui voient le MÊME panier ;
//   - l'horloge, fournie par PageBoutique (période par défaut, 1 s) ET par
//     ResumePanier (5 s) : chacun la sienne.
// =========================================================================
package fr.stahe.exemples.ex14

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import fr.stahe.exemples.ex14.fournisseurs.FournisseurHorloge
import fr.stahe.exemples.ex14.fournisseurs.FournisseurPanier
import fr.stahe.exemples.ex14.fournisseurs.Journal
import fr.stahe.exemples.ex14.fournisseurs.LocalHorloge
import fr.stahe.exemples.ex14.fournisseurs.LocalJournal

class Ex14Activity : ComponentActivity() {
    // le journal : un objet créé avec l'activité
    private val journal = Journal()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            // le journal, fourni à TOUT l'écran
            CompositionLocalProvider(LocalJournal provides journal) {
                ThemeExemples { PageBoutique() }
            }
        }
    }
}

// l'heure de l'horloge de la page : un petit composant, placé SOUS le fournisseur
@Composable
private fun HeurePage() {
    Text("Horloge de la page : ${LocalHorloge.current.toLocalTime().withNano(0)}", color = Color.Gray)
}

@Composable
fun PageBoutique() {
    // la page lit le journal : elle sera recomposée à chaque nouvelle ligne
    val journal = LocalJournal.current
    FournisseurHorloge {
        FournisseurPanier {
            PageExemple("Exemple 14 - les données fournies") {
                HeurePage()
                Catalogue()
                ResumePanier()
                Spacer(Modifier.height(12.dp))
                Text("Journal (fourni à tout l'écran)", style = MaterialTheme.typography.titleSmall)
                for (ligne in journal.lignes) {
                    Text(ligne, fontSize = 12.sp)
                }
            }
        }
    }
}
  • lignes 43-44 : le journal, un objet de l'activité, fourni à TOUT l'écran ;
  • lignes 60-65 : l'horloge (1 s) et le panier, fournis à la page ; [Catalogue] et [ResumePanier] voient le MÊME panier.

Image

4.6. Exemple 15 : réutiliser des comportements

Réutiliser un AFFICHAGE, c'est écrire un composant. Pour réutiliser un COMPORTEMENT (et son état), [Compose] offre deux outils : un composant « enveloppe », ou une fonction [remember...]. Une enveloppe qui surligne son contenu pendant qu'on le touche :

// =========================================================================
// ex15/Surligneur.kt - un comportement dans un composant "enveloppe"
// -------------------------------------------------------------------------
// La première façon de réutiliser un COMPORTEMENT (avec son état) : un
// composant qui enveloppe n'importe quel contenu et lui ajoute ce
// comportement :
//     Surligneur(couleur = ...) { ListItem(...) }
// Celui-ci surligne son contenu pendant qu'on le TOUCHE (le doigt posé sur
// l'écran), et, avec une souris (tablette, ChromeOS), pendant qu'on le
// SURVOLE. Chaque Surligneur a son propre état : deux lignes ne se gênent pas.
// =========================================================================
package fr.stahe.exemples.ex15

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput

@Composable
fun Surligneur(couleur: Color = Color(0xFFFFF3CD), contenu: @Composable () -> Unit) {
    var touche by remember { mutableStateOf(false) }
    // le survol par une souris : une "source d'interactions", dont on
    // observe l'état "survolé"
    val interactions = remember { MutableInteractionSource() }
    val survole by interactions.collectIsHoveredAsState()
    // un changement de couleur ANIMÉ (200 ms)
    val fond by animateColorAsState(if (touche || survole) couleur else Color.Transparent, tween(200), label = "fond")
    Box(
        Modifier
            .background(fond)
            .hoverable(interactions)
            // les contacts du doigt, au plus bas niveau : le doigt se pose,
            // puis se lève (ou le geste est annulé). requireUnconsumed = false :
            // même si le contenu (un bouton) a déjà traité l'appui
            .pointerInput(Unit) {
                awaitEachGesture {
                    awaitFirstDown(requireUnconsumed = false)
                    touche = true
                    waitForUpOrCancellation()
                    touche = false
                }
            },
    ) {
        contenu()
    }
}
  • lignes 38-39 : le survol par une souris (tablette, [ChromeOS]) : une « source d'interactions », dont on observe l'état « survolé » ;
  • ligne 41 : [animateColorAsState] : un changement de couleur ANIMÉ ;
  • lignes 49-54 : les contacts du doigt au plus bas niveau : pour chaque geste ([awaitEachGesture]), on attend que le doigt se pose, puis qu'il se lève. [requireUnconsumed = false] : même si le contenu (un bouton) a déjà traité l'appui.

Un composant qui porte une animation : un bouton qu'il faut maintenir appuyé une seconde, pendant qu'un anneau se remplit :

// =========================================================================
// ex15/BoutonAppuiLong.kt - un bouton à appui long
// -------------------------------------------------------------------------
//     BoutonAppuiLong(dureeMs = 1000, onAction = { supprimer() }) { Text(...) }
// L'action n'est exécutée que si le bouton est maintenu enfoncé pendant
// toute la durée : une protection contre les appuis malheureux. Un anneau
// se remplit pendant l'appui.
// Le remplissage est une ANIMATION : un Animatable est une valeur (de 0 à 1)
// qu'une coroutine fait varier, image après image (animateTo). Annuler la
// coroutine (le doigt se lève trop tôt) arrête l'animation.
// =========================================================================
package fr.stahe.exemples.ex15

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch

@Composable
fun BoutonAppuiLong(dureeMs: Int = 1000, onAction: () -> Unit, contenu: @Composable () -> Unit) {
    val progression = remember { Animatable(0f) }
    // la portée des coroutines du composant : elles sont annulées s'il disparaît
    val portee = rememberCoroutineScope()
    val action by rememberUpdatedState(onAction)

    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier.pointerInput(Unit) {
            detectTapGestures(
                // onPress : le doigt se pose ; tryAwaitRelease ATTEND qu'il se lève
                onPress = {
                    val remplissage = portee.launch {
                        progression.animateTo(1f, tween(dureeMs, easing = LinearEasing))
                        // l'animation est allée au bout : l'appui a duré assez longtemps
                        progression.snapTo(0f)
                        action()
                    }
                    tryAwaitRelease()
                    // le doigt se lève trop tôt : on arrête le remplissage, l'anneau se vide
                    if (remplissage.isActive) {
                        remplissage.cancel()
                        portee.launch { progression.animateTo(0f, tween(200)) }
                    }
                },
            )
        },
    ) {
        CircularProgressIndicator(
            progress = { progression.value },
            modifier = Modifier.size(22.dp),
            color = Color.Red,
            strokeWidth = 3.dp,
            trackColor = Color(0xFFFFEBEE),
        )
        Spacer(Modifier.width(8.dp))
        contenu()
    }
}
  • lignes 37-39 : un [Animatable], une valeur (de 0 à 1) qu'une coroutine fait varier image après image ; [rememberCoroutineScope] : une portée pour lancer des coroutines depuis un événement ;
  • lignes 47-58 : le doigt se pose : on lance le remplissage ([animateTo(1f)], en [dureeMs]) ; s'il va jusqu'au bout, l'action est exécutée. [tryAwaitRelease()] ATTEND que le doigt se lève : si le remplissage est encore en cours, on l'annule, et l'anneau se vide.

Une fonction [remember...], qui RENVOIE un objet après avoir mis en place son état et ses effets (c'est la convention de [Compose] : [rememberScrollState()], [rememberCoroutineScope()]...) :

// =========================================================================
// ex15/FocusAuDemarrage.kt - un comportement dans une fonction "remember..."
// -------------------------------------------------------------------------
// La seconde façon de réutiliser un comportement : une fonction
// @Composable qui RENVOIE un objet, après avoir mis en place son état et
// ses effets. C'est la convention de Compose (rememberScrollState(),
// rememberCoroutineScope()...) : son nom commence par "remember".
//     val focus = rememberFocusAuDemarrage()
//     OutlinedTextField(..., modifier = Modifier.focusRequester(focus))
// Le champ qui utilise ce FocusRequester reçoit le focus dès que l'écran
// est affiché.
// =========================================================================
package fr.stahe.exemples.ex15

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.focus.FocusRequester

@Composable
fun rememberFocusAuDemarrage(): FocusRequester {
    val focus = remember { FocusRequester() }
    // après le premier affichage : le champ existe, on lui donne le focus
    LaunchedEffect(Unit) { focus.requestFocus() }
    return focus
}

L'écran :

// =========================================================================
// ex15/Ex15Activity.kt - réutiliser des comportements
// -------------------------------------------------------------------------
// Trois outils de Compose pour réutiliser un COMPORTEMENT (et son état),
// et non un simple affichage :
//   - un composant "enveloppe" (Surligneur) ;
//   - un composant qui porte une animation (BoutonAppuiLong) ;
//   - une fonction "remember..." (rememberFocusAuDemarrage).
// =========================================================================
package fr.stahe.exemples.ex15

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples

class Ex15Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { PageComportements() } }
    }
}

@Composable
fun PageComportements() {
    val medecins = remember { mutableStateListOf("Mme Marie PELISSIER", "Mr Paul TATOU", "Melle Sophie DUPONT") }
    var message by remember { mutableStateOf("") }
    // le FocusRequester préparé par rememberFocusAuDemarrage
    val focus = rememberFocusAuDemarrage()
    var saisie by remember { mutableStateOf("") }

    PageExemple("Exemple 15 - réutiliser des comportements") {
        OutlinedTextField(
            value = saisie,
            onValueChange = { saisie = it },
            placeholder = { Text("ce champ a reçu le focus tout seul") },
            modifier = Modifier.fillMaxWidth().focusRequester(focus),
        )
        Spacer(Modifier.height(12.dp))
        Text("Touchez les lignes ; maintenez un bouton appuyé une seconde pour supprimer.", color = Color.Gray)
        medecins.forEachIndexed { i, m ->
            key(m) {
                // une ligne sur deux : la couleur par défaut ; les autres, du vert
                Surligneur(couleur = if (i % 2 == 0) Color(0xFFFFF3CD) else Color(0xFFD1E7DD)) {
                    ListItem(
                        // un fond transparent : on voit celui du Surligneur
                        colors = ListItemDefaults.colors(containerColor = Color.Transparent),
                        headlineContent = { Text(m) },
                        trailingContent = {
                            BoutonAppuiLong(onAction = {
                                medecins.remove(m)
                                message = "$m a été supprimé."
                            }) {
                                Text("Supprimer", color = Color.Red)
                            }
                        },
                    )
                }
            }
        }
        Spacer(Modifier.height(12.dp))
        if (message.isNotEmpty()) {
            Text(message, color = Color(0xFF3F51B5))
        }
    }
}

Image

Image

À gauche, le doigt est posé sur la première ligne : elle est surlignée ; à droite, le bouton « Supprimer » a été maintenu une seconde.

4.7. Exemple 16 : une fenêtre de confirmation réutilisable

Avec [Compose], une fenêtre ([AlertDialog]) est un composant comme un autre : elle est affichée tant qu'un état le demande. Pour poser une question et ATTENDRE la réponse, on écrit une petite classe dont la fonction [confirmer] est SUSPENDUE ([suspend], cf. exemple 19) :

// =========================================================================
// ex16/Confirmation.kt - « Êtes-vous sûr ? »
// -------------------------------------------------------------------------
// Avec Compose, une fenêtre (AlertDialog) est un composant comme un autre :
// elle est affichée tant qu'un état le demande. Pour poser une question
// et ATTENDRE la réponse, on écrit une petite classe, Confirmation, dont
// la fonction confirmer est une fonction SUSPENDUE (suspend, cf. exemple
// 19) : elle ouvre la fenêtre, puis attend, sans bloquer le téléphone, que
// l'utilisateur réponde. Le code appelant s'écrit alors très simplement,
// dans une coroutine :
//
//     if (confirmation.confirmer("Supprimer ce médecin ?")) { ... }
//
// Fermer la fenêtre SANS répondre (appui en dehors, bouton Retour du
// téléphone) est traité comme un "non".
// La fenêtre elle-même est affichée par FenetreConfirmation(confirmation),
// placée une fois dans l'écran.
// =========================================================================
package fr.stahe.exemples.ex16

import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.CompletableDeferred

class Confirmation {
    // la question posée (null : pas de fenêtre)
    var question by mutableStateOf<String?>(null)
        private set

    // la réponse attendue : une valeur qui arrivera PLUS TARD
    private var reponse: CompletableDeferred<Boolean>? = null

    suspend fun confirmer(message: String): Boolean {
        val attente = CompletableDeferred<Boolean>()
        reponse = attente
        question = message // la fenêtre s'affiche
        try {
            return attente.await() // la coroutine est suspendue jusqu'à la réponse
        } finally {
            question = null // la fenêtre disparaît
        }
    }

    fun repondre(oui: Boolean) {
        reponse?.complete(oui)
    }
}

// remember : la Confirmation survit aux recompositions de l'écran
@Composable
fun rememberConfirmation(): Confirmation = remember { Confirmation() }

@Composable
fun FenetreConfirmation(confirmation: Confirmation) {
    // ?: return : pas de question, rien à afficher
    val question = confirmation.question ?: return
    // AlertDialog : une fenêtre Material, avec titre, contenu et boutons
    AlertDialog(
        onDismissRequest = { confirmation.repondre(false) }, // fermée sans réponse : "non"
        title = { Text("Confirmation") },
        text = { Text(question) },
        dismissButton = { TextButton(onClick = { confirmation.repondre(false) }) { Text("Non, revenir") } },
        confirmButton = {
            Button(
                onClick = { confirmation.repondre(true) },
                colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)),
            ) { Text("Oui, confirmer") }
        },
    )
}
  • lignes 36-37 : la question posée ([null] : pas de fenêtre), un état observé ;
  • lignes 42-49 : [CompletableDeferred] : une valeur qui arrivera PLUS TARD. [confirmer] affiche la fenêtre, puis ATTEND la réponse ([await()]), sans bloquer le téléphone ; [finally] : dans tous les cas, la fenêtre disparaît ;
  • lignes 53-54 : un bouton de la fenêtre FOURNIT la réponse : la coroutine en attente reprend ;
  • lignes 63-68 : la fenêtre : rien s'il n'y a pas de question ([?: return]) ; la fermer sans répondre (appui en dehors, bouton Retour du téléphone) vaut « non ».

L'écran : une liste de contacts, et, après chaque suppression, un message éphémère ([Snackbar]) avec un bouton « Annuler », l'autre façon, très répandue sur mobile, de protéger l'utilisateur d'une erreur :

// =========================================================================
// ex16/Ex16Activity.kt - utiliser la fenêtre de confirmation
// -------------------------------------------------------------------------
// Une suppression, puis un message éphémère en bas de l'écran (Snackbar),
// avec un bouton "Annuler" : l'autre façon, très répandue sur mobile, de
// protéger l'utilisateur d'une erreur.
// Les deux attentes (la réponse à la question, le bouton "Annuler" du
// message) se font dans une COROUTINE, lancée par rememberCoroutineScope.
// =========================================================================
package fr.stahe.exemples.ex16

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import fr.stahe.exemples.commun.PageExemple
import fr.stahe.exemples.commun.ThemeExemples
import kotlinx.coroutines.launch

data class Contact(val id: Int, val nom: String)

class Ex16Activity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { ThemeExemples { PageContacts() } }
    }
}

@Composable
fun PageContacts() {
    val contacts = remember { mutableStateListOf(Contact(1, "Jean MARTIN"), Contact(2, "Alice DURAND"), Contact(3, "Luc GERMAN")) }
    var message by remember { mutableStateOf("") }
    val confirmation = rememberConfirmation()
    // la zone des messages éphémères, et une portée pour lancer des coroutines
    val messages = remember { SnackbarHostState() }
    val portee = rememberCoroutineScope()

    // une fonction suspendue : elle "attend" la réponse de l'utilisateur
    suspend fun supprimer(c: Contact) {
        if (!confirmation.confirmer("Supprimer le contact ${c.nom} ?")) {
            message = "Suppression abandonnée."
            return
        }
        val position = contacts.indexOf(c)
        contacts.remove(c)
        message = "${c.nom} a été supprimé."
        // un message éphémère au bas de l'écran ; showSnackbar attend sa
        // disparition, et dit si son bouton a été touché
        val resultat = messages.showSnackbar("${c.nom} supprimé", actionLabel = "Annuler", duration = SnackbarDuration.Short)
        if (resultat == SnackbarResult.ActionPerformed) {
            contacts.add(position, c)
            message = "${c.nom} est revenu."
        }
    }

    suspend fun toutSupprimer() {
        if (confirmation.confirmer("Supprimer les ${contacts.size} contacts ?")) {
            contacts.clear()
            message = "Tous les contacts ont été supprimés."
        }
    }

    PageExemple("Exemple 16 - une fenêtre de confirmation", messages = messages) {
        for (c in contacts) {
            ListItem(
                leadingContent = { Icon(Icons.Default.Person, contentDescription = null) },
                headlineContent = { Text(c.nom) },
                trailingContent = {
                    // launch : une coroutine, pour pouvoir appeler la fonction suspendue
                    IconButton(onClick = { portee.launch { supprimer(c) } }) {
                        Icon(Icons.Outlined.Delete, contentDescription = "supprimer ${c.nom}", tint = Color.Red)
                    }
                },
            )
        }
        if (contacts.isEmpty()) {
            ListItem(headlineContent = { Text("Aucun contact.", color = Color.Gray) })
        }
        Spacer(Modifier.height(12.dp))
        Button(
            onClick = { portee.launch { toutSupprimer() } },
            // le bouton est désactivé quand la liste est vide
            enabled = contacts.isNotEmpty(),
            colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)),
        ) { Text("Tout supprimer") }
        Spacer(Modifier.height(12.dp))
        if (message.isNotEmpty()) {
            Text(message, color = Color(0xFF3F51B5))
        }
    }
    // la fenêtre, quand une question est posée
    FenetreConfirmation(confirmation)
}
  • lignes 60-61 : la zone des messages éphémères, passée à [PageExemple] (qui la donne au [Scaffold]), et une portée de coroutines ;
  • lignes 64-77 : une fonction suspendue qui se lit comme du code ordinaire : on demande, on attend la réponse ; on supprime ; on affiche le message, on ATTEND sa disparition ([showSnackbar] dit si son bouton a été touché) et l'on annule si besoin ;
  • ligne 95 : [portee.launch [ ... ]] : une coroutine, pour pouvoir appeler une fonction suspendue depuis un événement ;
  • ligne 117 : la fenêtre, placée une fois dans l'écran.

Image

Image