Mises en page dans Jetpack Compose

1. Introduction

Dans l'atelier de programmation sur les bases de Jetpack Compose, vous avez appris à créer des interfaces utilisateur simples avec Compose à l'aide de composables tels que Text, ainsi que de composables de mise en page flexibles, tels que Column et Row, qui vous permettent de positionner des éléments sur l'écran (respectivement verticalement et horizontalement) et de configurer leur alignement. Si vous ne souhaitez pas que les éléments soient affichés verticalement ou horizontalement, Box vous permet de les placer au premier plan et/ou à l'arrière-plan.

fbd450e8eab10338.png

Vous pouvez utiliser ces composants de mise en page standards pour créer des interfaces utilisateur semblables à celle-ci :

d2c39f3c2416c321.png

@Composable
fun PhotographerProfile(photographer: Photographer) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(...)
        Column {
            Text(photographer.name)
            Text(photographer.lastSeenOnline, ...)
        }
    }
}

Grâce aux propriétés de réutilisabilité et de composabilité de Compose, vous pouvez créer vos propres composables en combinant les différentes parties nécessaires au niveau d'abstraction correct dans une nouvelle fonction modulable.

Dans cet atelier de programmation, vous apprendrez à utiliser le niveau d'abstraction d'interface utilisateur le plus élevé de Compose, Material Design, ainsi que des composables de bas niveau, comme Layout qui vous permet de mesurer et de positionner des éléments sur l'écran.

Si vous souhaitez créer une UI basée sur Material Design, Compose dispose de composablesMaterial Components intégrés que vous pouvez utiliser comme indiqué dans cet atelier de programmation. Si vous ne souhaitez pas utiliser Material Design ou si vous avez l'intention de créer un élément qui ne figure pas dans les spécifications de Material Design, vous pourrez également créer des mises en page personnalisées en suivant les explications de cet atelier de programmation.

Objectifs de l'atelier

Dans cet atelier de programmation, vous apprendrez :

  • comment utiliser des composables Material Components ;
  • ce que sont les modificateurs et comment les utiliser dans des mises en page ;
  • comment créer votre mise en page personnalisée ;
  • dans quels cas utiliser les fonctionnalités intrinsèques.

Conditions préalables

  • Connaître la syntaxe du langage Kotlin, y compris les lambdas
  • Connaître les bases de Compose

Ce dont vous avez besoin

2. Démarrer un nouveau projet Compose

Pour démarrer un nouveau projet Compose, ouvrez Android Studio Bumblebee et sélectionnez ensuite Start a new Android Studio project (Démarrer un nouveau projet Android Studio) comme illustré ci-dessous :

ec53715fe31913e6.jpeg

Si l'écran ci-dessus ne s'affiche pas, accédez à File (Fichier) > New (Nouveau) > New Project (Nouveau projet).

Lors de la création d'un projet, sélectionnez Empty Compose Activity (Activité Compose vide) dans la liste des modèles disponibles.

a67ba73a4f06b7ac.png

Cliquez sur Next (Suivant) et configurez votre projet selon la méthode habituelle. Veillez à sélectionner une minimumSdkVersion ayant au moins le niveau d'API 21, ce qui correspond au niveau d'API minimum accepté par Compose.

Lorsque vous sélectionnez le modèle Empty Compose Activity, le code suivant est automatiquement généré dans le projet :

  • Le projet est déjà configuré afin d'utiliser Compose.
  • Le fichier AndroidManifest.xml est créé.
  • Le fichier app/build.gradle (ou build.gradle (Module: YourApplicationName.app)) importe les dépendances Compose et permet à Android Studio de fonctionner avec Compose à l'aide de l'indicateur buildFeatures { compose true }.
android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
    }
}

dependencies {
    ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation 'androidx.activity:activity-compose:1.4.0'
    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    ...
}

Solution de l'atelier de programmation

Le code nécessaire à la solution de cet atelier de programmation est disponible sur GitHub :

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

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

Vous trouverez le code de la solution dans le projet LayoutsCodelab. Nous vous recommandons de suivre l'atelier de programmation étape par étape, à votre rythme, et de consulter la solution si vous le jugez nécessaire. Au cours de cet atelier de programmation, vous découvrirez des extraits de code que vous devrez ajouter au projet.

3. Modificateurs

Les modificateurs vous permettent de "décorer" un composable. Vous pouvez modifier son comportement et son apparence, ajouter des informations telles que des niveaux d'accessibilité, traiter des entrées utilisateur et même ajouter des interactions de haut niveau pour faire en sorte qu'un élément soit cliquable, déplaçable, zoomable ou déroulant. Les modificateurs sont des objets Kotlin standards. Vous pouvez les attribuer à des variables et les réutiliser. Vous pouvez également enchaîner plusieurs modificateurs afin de les composer.

Nous allons maintenant implémenter la mise en page de profil présentée dans la section d'introduction :

d2c39f3c2416c321.png

Ouvrez MainActivity.kt et ajoutez le code suivant :

@Composable
fun PhotographerCard() {
    Column {
        Text("Alfred Sisley", fontWeight = FontWeight.Bold)
        // LocalContentAlpha is defining opacity level of its children
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text("3 minutes ago", style = MaterialTheme.typography.body2)
        }
    }
}

@Preview
@Composable
fun PhotographerCardPreview() {
    LayoutsCodelabTheme {
        PhotographerCard()
    }
}

Avec l'aperçu :

bf29f2c3f5d6a27.png

Ensuite, vous pouvez choisir d'afficher un espace réservé pendant le chargement de l'image. Pour ce faire, vous pouvez utiliser une Surface dans laquelle vous spécifiez une forme circulaire et la couleur de l'espace réservé. Pour indiquer sa taille, vous pouvez utiliser le modificateur size :

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

84f2bb229d67987b.png

À ce stade, nous souhaitons apporter quelques améliorations :

  1. Nous aimerions séparer l'espace réservé du texte.
  2. Nous aimerions que le texte soit centré verticalement.

Pour la première amélioration, vous pouvez utiliser Modifier.padding sur la Column qui contient le texte afin d'ajouter de l'espace au niveau de l'élément start du composable, de manière à séparer l'image du texte. Pour la deuxième amélioration, certaines mises en page proposent des modificateurs qui ne peuvent être appliqués qu'à elles seules et à leurs caractéristiques. Par exemple, les composables d'une Row peuvent accéder à certains modificateurs (à partir du récepteur RowScope du contenu de Row) qui sont pertinents à cet emplacement, tels que weight ou align. Cette limitation offre une sûreté de typage, en empêchant toute utilisation accidentelle d'un modificateur non pertinent dans une autre mise en page. Par exemple, weight n'est pas pertinent dans Box. Vous ne pourrez donc pas l'utiliser, car cela générerait une erreur au moment de la compilation.

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column(
            modifier = Modifier
                .padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        ) {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

Avec l'aperçu :

1542fadc7f68feb2.png

La plupart des composables acceptent un paramètre de modificateur facultatif qui les rend plus modulables, ce qui permet à l'appelant de les modifier. Si vous créez votre propre composable, pensez à utiliser un modificateur comme paramètre, définissez-le par défaut sur Modifier (c'est-à-dire un modificateur vide n'ayant aucun effet) et appliquez-le au composable racine de votre fonction. Dans ce cas :

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier) { ... }
}

L'ordre des modificateurs a de l'importance

Dans le code, observez comment enchaîner plusieurs modificateurs les uns après les autres en utilisant les fonctions d'extension de fabrique (à savoir Modifier.padding(start = 8.dp).align(Alignment.CenterVertically)).

Lorsque vous enchaînez des modificateurs, veuillez tenir compte de l'ordre, qui est un facteur important. Étant donné que les modificateurs sont concaténés dans un seul argument, l'ordre a une incidence sur le résultat final.

Si vous voulez que le profil Photographer soit cliquable et contienne des marges intérieures, vous pouvez utiliser un code semblable à ceci :

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(16.dp)
        .clickable(onClick = { /* Ignoring onClick */ })
    ) {
        ...
    }
}

Utilisation d'un aperçu interactif ou exécution dans un émulateur :

c15a1050b051617f.gif

Comme vous pouvez le voir, la zone n'est pas cliquable. Cela est dû au fait que padding a été appliqué avant le modificateur clickable. Si le modificateur padding est appliqué après clickable, la marge intérieure est incluse dans la zone cliquable :

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

Utilisation d'un aperçu interactif ou exécution dans un émulateur :

a1ea4c8e16d61ffa.gif

Donnez libre cours à votre imagination ! Les modificateurs vous offrent une grande flexibilité en ce qui concerne la modification de votre composable. Par exemple, si vous souhaitez ajouter un espacement extérieur, modifier la couleur d'arrière-plan du composable et arrondir les coins de Row, vous pouvez utiliser le code suivant :

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(8.dp)
        .clip(RoundedCornerShape(4.dp))
        .background(MaterialTheme.colors.surface)
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

Utilisation d'un aperçu interactif ou exécution dans un émulateur :

4c7652fc71ccf8dc.gif

Nous examinerons les détails techniques des modificateurs dans la suite de cet atelier de programmation.

4. API Slot

Compose fournit des composables Material Components de haut niveau que vous pouvez utiliser pour créer votre UI. Puisqu'il s'agit de blocs de construction destinés à la création d'une UI, vous devez encore fournir les informations concernant le contenu à afficher à l'écran.

Les API Slot constituent un schéma proposé par Compose pour ajouter une couche de personnalisation au-dessus des composables ; dans ce cas, les composables Material Components disponibles.

Prenons l'exemple suivant :

Si vous envisagez d'utiliser un bouton Material, sachez que son apparence et son contenu sont définis par un guide. On peut traduire cela en une API simple :

Button(text = "Button")

b3cb99320ec18268.png

Cependant, il arrive bien souvent que l'on souhaite personnaliser des composants bien au-delà de ce qui était prévu. Vous pouvez essayer d'ajouter un paramètre pour chaque élément personnalisable, mais cela devient rapidement incontrôlable :

Button(
    text = "Button",
    icon: Icon? = myIcon,
    textStyle = TextStyle(...),
    spacingBetweenIconAndText = 4.dp,
    ...
)

ef5893f332864e28.png

Par conséquent, au lieu d'ajouter plusieurs paramètres pour personnaliser le composant de manière imprévisible, des emplacements (Slots) ont été ajoutés. Ces emplacements laissent dans l'UI un espace vide que le développeur peut remplir comme bon lui semble.

fccfb817afa8876e.png

Par exemple, dans le cas d'un bouton, l'intérieur de l'élément peut être laissé vide pour que vous puissiez y insérer une icône et du texte :

Button {
    Row {
        MyImage()
        Spacer(4.dp)
        Text("Button")
    }
}

Pour cela, nous fournissons une API pour le bouton qui utilise un lambda de composable enfant (content: @Composable () -> Unit). Cela vous permet de définir votre propre composable pour qu'il soit émis à l'intérieur du bouton.

@Composable
fun Button(
    modifier: Modifier = Modifier,
    onClick: (() -> Unit)? = null,
    ...
    content: @Composable () -> Unit
)

Notez que ce lambda, que nous avons nommé content, est le dernier paramètre. Cela nous permet d'utiliser la syntaxe lambda de fin pour insérer du contenu dans le bouton de manière structurée.

Compose utilise énormément les emplacements dans des composants plus complexes, comme la barre d'application supérieure.

4365ce9b02ec2805.png

Dans ce cas, nous pouvons personnaliser des éléments autres que le titre :

2decc9ec64c79a84.png

Exemple d'utilisation :

TopAppBar(
    title = {
        Text(text = "Page title", maxLines = 2)
    },
    navigationIcon = {
        Icon(myNavIcon)
    }
)

Lors de la création de vos propres composables, vous pouvez utiliser le schéma Slots API afin de les rendre plus faciles à réutiliser.

Dans les sections suivantes, nous examinerons les différents composables Material Components disponibles, ainsi que la façon de les utiliser lors de la création d'une application Android.

5. Material Components (Composants Material)

Compose s'accompagne de composables Material Components intégrés que vous pouvez utiliser pour créer votre application. Le composable de niveau le plus élevé est Scaffold.

Scaffold

Scaffold vous permet d'implémenter une UI avec la structure de mise en page Material Design de base. Il fournit des emplacements pour les composants Material de niveau supérieur les plus courants, comme TopAppBar, BottomAppBar, FloatingActionButton et Drawer. Avec le composable Scaffold, vous avez la garantie que ces composants sont positionnés et interagissent correctement.

Sur la base du modèle Android Studio généré, nous allons modifier l'exemple de code pour qu'il utilise Scaffold. Ouvrez MainActivity.kt, et supprimez les composables Greeting et GreetingPreview, dans la mesure où ils ne seront pas utilisés.

Créez un composable appelé LayoutsCodelab que nous modifierons dans l'atelier de programmation :

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.codelab.layouts.ui.LayoutsCodelabTheme

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LayoutsCodelabTheme {
                LayoutsCodelab()
            }
        }
    }
}

@Composable
fun LayoutsCodelab() {
    Text(text = "Hi there!")
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

Si vous voyez la fonction d'aperçu de Compose qui doit être annotée avec @Preview, LayoutsCodelab apparaîtra comme ceci :

bd1c58d4497f523f.png

Ajoutons le composable Scaffold à notre exemple afin de disposer d'une structure Material Design standard. Tous les paramètres de la Scaffold API sont facultatifs, à l'exception du contenu principal qui est de type @Composable (InnerPadding) -> Unit : le lambda reçoit une marge intérieure comme paramètre. Il s'agit de la marge intérieure qui doit être ajoutée au composable racine du contenu pour limiter les éléments de manière appropriée sur l'écran. Commençons simplement en ajoutant Scaffold sans aucun autre composant Material :

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Text(text = "Hi there!", modifier = Modifier.padding(innerPadding))
    }
}

Avec l'aperçu :

54b175d305766292.png

Pour qu'il y ait une Column avec le contenu principal de l'écran, le modificateur Column doit être appliqué à :

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text(text = "Hi there!")
            Text(text = "Thanks for going through the Layouts codelab")
        }
    }
}

Avec l'aperçu :

aceda77e27f25fe9.png

Pour faire en sorte que notre code soit plus facile à réutiliser et à tester, nous devons le structurer en petites unités. Pour cela, nous allons créer une autre fonction modulable avec le contenu de l'écran.

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}

Les applications Android affichent souvent une barre d'application supérieure avec des informations sur l'écran actuel, les options de navigation et différentes actions. Nous allons ajouter cela à notre exemple.

TopAppBar

Scaffold propose un emplacement pour une barre d'application supérieure (TopAppBar) avec le paramètre topBar de type @Composable () -> Unit, ce qui signifie que tout composable peut y être renseigné. Par exemple, si vous souhaitez simplement qu'il contienne un texte de style h3, Text peut être utilisé comme suit dans l'emplacement fourni :

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            Text(
                text = "LayoutsCodelab",
                style = MaterialTheme.typography.h3
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Avec l'aperçu :

6adf05bb92b48b76.png

Cependant, comme pour la plupart des composants Material, Compose dispose d'un composable TopAppBar qui contient des emplacements pour un titre, une icône de navigation et des actions. Il intègre également des paramètres par défaut qui s'adaptent aux spécifications recommandées par Material, comme la couleur à utiliser sur chaque composant.

Conformément au schéma Slots API, nous voulons que l'emplacement title de TopAppBar contienne un Text avec le titre de l'écran :

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Avec l'aperçu :

c93d09851d6560c7.png

Les barres d'application supérieures contiennent généralement des tâches. Dans cet exemple, vous allez ajouter un bouton favorite sur lequel vous pouvez appuyer lorsque vous pensez avoir appris quelque chose. Compose intègre également des icônes Material prédéfinies ; close, favorite et menu, par exemple.

L'emplacement correspondant aux tâches dans la barre d'application supérieure est le paramètre actions qui utilise Row en interne, de sorte que plusieurs actions soient positionnées horizontalement. Pour utiliser l'une des icônes prédéfinies, vous pouvez utiliser le composable IconButton à l'intérieur duquel se trouve une Icon :

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

Avec l'aperçu :

b2d81ccec4667ef5.png

En règle générale, les actions modifient l'état de votre application d'une manière ou d'une autre. Pour en savoir plus sur l'état, découvrez les principes de base de la gestion d'état dans l'atelier de programmation sur les bases de Compose.

Positionnement des modificateurs

Lorsque vous créez un composable, il est recommandé de définir par défaut un paramètre modifier sur Modifier pour que le composable en question soit plus facile à réutiliser. Notre composable BodyContent utilise déjà un modificateur comme paramètre. Supposons que vous souhaitiez ajouter une marge intérieure à BodyContent. Dans ce cas, où devez-vous placer le modificateur padding ?

Deux possibilités s'offrent à vous :

  1. Appliquez le modificateur au seul enfant direct à l'intérieur du composable afin que tous les appels vers BodyContent appliquent la marge intérieure supplémentaire :
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(8.dp)) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}
  1. Appliquez le modificateur lors de l'appel du composable qui ajoutera la marge intérieure supplémentaire uniquement lorsque cela s'avérera nécessaire :
@Composable
fun LayoutsCodelab() {
    Scaffold(...) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding).padding(8.dp))
    }
}

Le choix de l'emplacement dépend entièrement du type de composable et du cas d'utilisation. Si le modificateur est inhérent au composable, placez-le à l'intérieur ; dans le cas contraire, placez-le à l'extérieur. Dans le cas présent, nous allons opter pour la deuxième solution. Dans la mesure où nous ne forcerons pas systématiquement l'application d'une marge intérieure lorsque nous appellerons BodyContent, cela doit s'effectuer au cas par cas.

Les modificateurs peuvent être enchaînés en appelant chaque fonction de modificateur successive sur la fonction précédente. Si aucune méthode de chaînage n'est disponible, vous pouvez utiliser .then(). Dans cet exemple, nous commençons par modifier (en minuscules), ce qui signifie que la chaîne est créée au-dessus de la chaîne transmise en tant que paramètre.

Autres d'icônes

Outre les icônes mentionnées précédemment, vous pouvez utiliser toute la liste des icônes Material en ajoutant une nouvelle dépendance au projet. Si vous souhaitez tester ces icônes, ouvrez le fichier app/build.gradle (ou build.gradle (Module: app)) et importez la dépendance ui-material-icons-extended :

dependencies {
  ...
  implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

Exercez-vous et n'hésitez pas à modifier les icônes de TopAppBar suivant vos besoins.

Autres tâches

Scaffold et TopAppBar sont deux exemples de composables qui peuvent être utilisés pour créer une application de type Material. Cette même approche peut être adoptée pour d'autres composants Material, comme BottomNavigation ou BottomDrawer. Pour vous exercer, nous vous invitons à compléter les emplacements Scaffold avec ces API en appliquant la même procédure que celle que nous avons suivie jusqu'à présent.

6. Utilisation de listes

L'affichage d'une liste d'éléments est une méthode courante dans les applications. Jetpack Compose permet d'implémenter facilement ce schéma avec les composables Column et Row. Le kit propose également des listes inactives qui composent et mettent en page uniquement les éléments visibles actuellement.

Pour vous exercer, vous allez créer une liste verticale de 100 éléments à l'aide du composable Column :

@Composable
fun SimpleList() {
    Column {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

Étant donné que Column ne gère pas le défilement par défaut, certains éléments ne sont pas visibles, car ils se trouvent en dehors de l'écran. Ajoutez le modificateur verticalScroll pour activer le défilement dans Column :

@Composable
fun SimpleList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberScrollState()

    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

Liste inactive

Column effectue le rendu de tous les éléments de la liste, même ceux qui ne sont pas visibles à l'écran. Cela constitue un problème de performances à mesure que la taille de la liste augmente. Pour éviter ce problème, utilisez LazyColumn, un composable qui effectue uniquement le rendu des éléments visibles à l'écran, ce qui permet de réaliser des gains de performance et de se passer du modificateur scroll.

LazyColumn comporte un DSL pour décrire le contenu de sa liste. Vous allez utiliser items qui peut utiliser un nombre comme taille de liste. Il accepte également les tableaux et les listes (pour en savoir plus, consultez la section de la documentation traitant des listes).

@Composable
fun LazyList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            Text("Item #$it")
        }
    }
}

1c747e54111e28c.gif

Affichage d'images

Comme nous l'avons vu précédemment avec PhotographCard, Image est un composable que vous pouvez utiliser pour afficher un Bitmap ou une image vectorielle. Si l'image est extraite à distance, le processus comprend davantage d'étapes, étant donné que votre application doit télécharger l'asset, le décoder dans un bitmap et enfin en effectuer le rendu dans une Image.

Pour simplifier ces étapes, vous utiliserez la bibliothèque Coil qui fournit des composables qui exécutent efficacement ces tâches.

Ajoutez la dépendance Coil dans le fichier build.gradle de votre projet :

// build.gradle
implementation 'io.coil-kt:coil-compose:1.4.0'

Ajoutez l'autorisation INTERNET à votre fichier manifeste, car une image distante sera extraite :

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

Créez à présent un composable d'élément dans lequel vous afficherez une image et, à côté de celle-ci, l'index d'élément :

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {

        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

Remplacez ensuite le composable Text dans votre liste par ImageListItem :

@Composable
fun ImageList() {
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            ImageListItem(it)
        }
    }
}

9c6a666c57a84211.gif

Défilement de liste

Nous allons à présent contrôler manuellement la position de défilement de la liste. Nous allons ajouter deux boutons permettant un défilement fluide vers le haut et vers le bas de la liste. Pour éviter que le rendu de la liste ne soit bloqué pendant le défilement, les API de défilement sont des fonctions de suspension. Nous devons donc les appeler dans une coroutine. Pour ce faire, nous pouvons créer une fonction CoroutineScope à l'aide de la fonction rememberCoroutineScope afin de créer des coroutines à partir des gestionnaires d'événements de bouton. Cette CoroutineScope suit le cycle de vie du site d'appel. Pour en savoir plus sur les cycles de vie des composables, les coroutines et les effets secondaires, consultez ce guide.

val listSize = 100
// We save the scrolling position with this state
val scrollState = rememberLazyListState()
// We save the coroutine scope where our animated scroll will be executed
val coroutineScope = rememberCoroutineScope()

Pour terminer, nous allons ajouter des boutons qui contrôleront le défilement :

Row {
    Button(onClick = {
        coroutineScope.launch {
            // 0 is the first item index
            scrollState.animateScrollToItem(0)
        }
    }) {
        Text("Scroll to the top")
    }

    Button(onClick = {
        coroutineScope.launch {
            // listSize - 1 is the last index of the list
            scrollState.animateScrollToItem(listSize - 1)
        }
    }) {
        Text("Scroll to the end")
    }
}

9bc52801a90401f3.gif

Code complet de cette section

import androidx.compose.foundation.layout.Column
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import kotlinx.coroutines.launch

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

@Composable
fun ScrollingList() {
    val listSize = 100
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()
    // We save the coroutine scope where our animated scroll will be executed
    val coroutineScope = rememberCoroutineScope()

    Column {
        Row {
            Button(onClick = {
                coroutineScope.launch {
                    // 0 is the first item index
                    scrollState.animateScrollToItem(0)
                }
            }) {
                Text("Scroll to the top")
            }

            Button(onClick = {
                coroutineScope.launch {
                    // listSize - 1 is the last index of the list
                    scrollState.animateScrollToItem(listSize - 1)
                }
            }) {
                Text("Scroll to the end")
            }
        }

        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

7. Créer une mise en page personnalisée

Compose promeut la réutilisation des composables sous la forme de petites unités qui peuvent s'avérer suffisantes pour certaines mises en page personnalisées, en combinant des composables intégrés tels que Column, Row ou Box.

Cependant, il arrive que vous deviez créer quelque chose de spécifique à votre application, ce qui suppose la mesure et la disposition manuelles d'éléments enfants. Pour ce faire, vous pouvez utiliser le composable Layout. En fait, ce composable est utilisé pour créer toutes les mises en page de niveau supérieur, comme Column et Row.

Avant d'examiner en détail la création de mises en page personnalisées, il est nécessaire d'en apprendre davantage sur les principes des mises en page dans Compose.

Principes des mises en page dans Compose

Lorsqu'elles sont appelées, certaines fonctions modulables émettent un élément d'UI qui est ajouté à une arborescence d'interface qui sera affichée à l'écran. Chaque émission (ou élément) comprend un seul parent et potentiellement de nombreux enfants. Un emplacement, position (x, y), figure également dans le parent, ainsi qu'une taille, width et height.

Les éléments sont invités à se mesurer en respectant certaines contraintes. Ces contraintes limitent la width et height maximales d'un élément. Si un élément possède des éléments enfants, il peut mesurer chacun d'eux afin de déterminer sa propre taille. Dès qu'un élément a indiqué sa propre taille, il a la possibilité de positionner ses éléments enfants par rapport à lui. Nous y reviendrons plus en détail lors de la création de la mise en page personnalisée.

L'interface utilisateur de Compose n'autorise pas les mesures en plusieurs passages. Cela signifie qu'un élément de mise en page ne peut mesurer aucun de ses enfants plusieurs fois dans le but de tester différentes configurations de mesure. Les mesures en un seul passage favorisent les performances en permettant à Compose de traiter efficacement les arborescences d'interface comportant de nombreux niveaux. Si un élément de mise en page mesurait deux fois son élément enfant et que celui-ci mesurait deux fois l'un de ses éléments enfants, et ainsi de suite, une seule tentative de mise en page de l'ensemble de l'UI entraînerait une charge de travail considérable. De ce fait, il pourrait être difficile de conserver le niveau de performance de l'application. Cependant, il arrive que vous ayez absolument besoin d'autres informations que celles renvoyées par une seule mesure enfant. Plusieurs possibilités s'offrent alors à vous. Nous y reviendrons par la suite.

Utilisation du modificateur de mise en page

Utilisez le modificateur layout pour déterminer manuellement comment mesurer et positionner un élément. En règle générale, un modificateur layout personnalisé présente la structure suivante :

fun Modifier.customLayoutModifier(...) = Modifier.layout { measurable, constraints ->
  ...
})

Lors de l'utilisation du modificateur layout, vous obtenez deux paramètres lambda :

  • measurable : enfant à mesurer et à positionner
  • constraints : valeurs minimale et maximale pour la largeur et la hauteur de l'élément enfant

Supposons que vous souhaitiez afficher un Text à l'écran, et contrôler la distance entre le haut et la ligne de base de la première ligne de texte. Pour cela, vous devez positionner manuellement le composable sur l'écran à l'aide du modificateur layout. Reportez-vous au comportement souhaité dans l'illustration ci-dessous où la distance entre le haut et la première ligne de base est de 24.dp :

4ee1054702073598.png

Commençons par créer un modificateur firstBaselineToTop :

fun Modifier.firstBaselineToTop(
  firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...
    }
)

La première chose à faire est de mesurer le composable. Comme nous l'avons indiqué dans la section Principes de mise en page dans Compose, vous ne pouvez mesurer vos éléments enfants qu'une seule fois.

Mesurez le composable en appelant measurable.measure(constraints). Lors de l'appel de measure(constraints), vous pouvez transmettre les contraintes du composable disponibles dans le paramètre lambda constraints ou créer les vôtres. Un appel measure() sur Measurable donne comme résultat un Placeable qui peut être positionné en appelant placeRelative(x, y), comme nous le ferons ultérieurement.

Pour ce cas d'utilisation, ne limitez pas davantage la mesure. Utilisez simplement les contraintes données :

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        ...
    }
)

Maintenant que le composable a été mesuré, vous devez calculer sa taille et la spécifier en appelant la méthode layout(width, height) qui accepte également un lambda utilisé pour positionner le contenu.

Dans le cas présent, la largeur de notre composable sera la width du composable mesuré et la hauteur sera la height du composable, avec la hauteur souhaitée entre le haut et la ligne de base moins la première ligne de base :

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        // Check the composable has a first baseline
        check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
        val firstBaseline = placeable[FirstBaseline]

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            ...
        }
    }
)

Vous pouvez maintenant positionner le composable à l'écran en appelant placeable.placeRelative(x, y). Si vous n'appelez pas placeRelative, le composable ne sera pas visible. placeRelative ajuste automatiquement la position de l'élément positionnable en fonction de la layoutDirection actuelle.

Dans ce cas, la position y du texte correspond à la marge supérieure moins la position de la première ligne de base :

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            // Where the composable gets placed
            placeable.placeRelative(0, placeableY)
        }
    }
)

Pour vérifier que cela fonctionne comme prévu, vous pouvez utiliser ce modificateur sur un Text, comme illustré sur l'image ci-dessus :

@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
  }
}

@Preview
@Composable
fun TextWithNormalPaddingPreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.padding(top = 32.dp))
  }
}

Avec l'aperçu :

dccb4473e2ca09c6.png

Utilisation du composable Layout

Vous pouvez contrôler la façon dont un seul composable est mesuré et disposé à l'écran, mais cela peut également s'avérer nécessaire pour un groupe de composables. Pour ce faire, vous pouvez utiliser le composable Layout afin de contrôler manuellement comment mesurer et positionner les éléments enfants de la mise en page. En règle générale, un composable qui utilise Layout présente la structure suivante :

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // custom layout attributes
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

Au minimum, les paramètres requis pour un CustomLayout sont modifier et content ; ils sont ensuite transmis à Layout. Dans le lambda de fin de Layout (de type MeasurePolicy), vous obtenez les mêmes paramètres lambda qu'avec le modificateur layout.

Pour voir Layout en action, commençons par implémenter un composable Column rudimentaire à l'aide de Layout pour bien comprendre l'API. Par la suite, nous créerons quelque chose de plus complexe pour montrer la polyvalence du composable Layout.

Implémentation d'un composable Column de base

Notre implémentation personnalisée de Column dispose les éléments verticalement. Par souci de simplicité, notre mise en page occupe un maximum d'espace dans son élément parent.

Créez un composable appelé MyOwnColumn et ajoutez la structure courante d'un composable Layout :

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

Comme précédemment, la première chose à faire est de mesurer les éléments enfants qui ne peuvent l'être qu'une seule fois. Comme c'est le cas pour le modificateur de mise en page, dans le paramètre lambda measurables, vous recevez tout le content qu'il est possible de mesurer en appelant measurable.measure(constraints).

Pour ce cas d'utilisation, nous ne limiterons pas davantage nos affichages enfants. Lors de la mesure des éléments enfants, vous devez effectuer le suivi de width et de la height maximale de chaque ligne pour qu'il soit possible, par la suite, de les positionner correctement sur l'écran :

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }
    }
}

Vous disposez à présent de la liste des éléments enfants mesurés dans la logique. Avant de les positionner sur l'écran, vous devez calculer la taille de notre version de Column. Comme elle est aussi grande que son parent, sa taille correspond aux contraintes transmises par le parent. Spécifiez la taille de notre propre Column en appelant la méthode layout(width, height), ce qui vous donne également le lambda utilisé pour positionner les éléments enfants :

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Measure children - code in the previous code snippet
        ...

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children
        }
    }
}

Pour terminer, nous allons positionner nos éléments enfants sur l'écran en appelant placeable.placeRelative(x, y). Pour positionner les éléments enfants verticalement, nous effectuons le suivi de la coordonnée y jusqu'à laquelle ces éléments ont été placés. Le code final de MyOwnColumn se présente comme suit :

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }

        // Track the y co-ord we have placed children up to
        var yPosition = 0

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children in the parent layout
            placeables.forEach { placeable ->
                // Position item on the screen
                placeable.placeRelative(x = 0, y = yPosition)

                // Record the y co-ord placed up to
                yPosition += placeable.height
            }
        }
    }
}

MyOwnColumn en action

Observons MyOwnColumn sur l'écran en l'utilisant dans le composable BodyContent. Remplacez le contenu de BodyContent par le code suivant :

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    MyOwnColumn(modifier.padding(8.dp)) {
        Text("MyOwnColumn")
        Text("places items")
        Text("vertically.")
        Text("We've done it by hand!")
    }
}

Avec l'aperçu :

e69cdb015e4d8abe.png

8. Mise en page personnalisée complexe

Maintenant que nous avons parcouru les principes de base de Layout, nous allons créer un exemple plus complexe pour montrer la polyvalence de l'API. Nous allons créer la grille décalée de l'étude Material Owl personnalisée que vous pouvez voir au centre de l'illustration ci-dessous :

7a54fe8390fe39d2.png

Dans la grille décalée de l'application Owl, les éléments sont disposés verticalement et une colonne est remplie à la fois, étant donné un nombre n de lignes. Cela n'est pas possible avec un Row de Columns, car vous n'obtenez pas le décalage de la mise en page. Avoir un Column de Rows est possible si vous préparez les données pour qu'elles soient affichées verticalement.

Cependant, la mise en page personnalisée vous donne également la possibilité de limiter la hauteur de tous les éléments de la grille décalée. Aussi, pour mieux contrôler la mise en page et apprendre à créer une mise en page personnalisée, nous allons mesurer et positionner nous-mêmes les éléments enfants.

Pour que la grille puisse être réutilisée selon différentes orientations, vous pouvez utiliser comme paramètre le nombre de lignes à afficher à l'écran. Puisque cette information doit apparaître lorsque la mise en page est appelée, elle est transmise en tant que paramètre :

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

Ici encore, la première chose à faire est de mesure les éléments enfants. Pour rappel, vous ne pouvez mesurer vos éléments enfants qu'une seule fois.

Pour ce cas d'utilisation, nous ne limiterons pas davantage nos affichages enfants. Lors de la mesure des éléments enfants, il faut également effectuer le suivi de la width et de la height maximale de chaque ligne :

Layout(
    modifier = modifier,
    content = content
) { measurables, constraints ->

    // Keep track of the width of each row
    val rowWidths = IntArray(rows) { 0 }

    // Keep track of the max height of each row
    val rowHeights = IntArray(rows) { 0 }

    // Don't constrain child views further, measure them with given constraints
    // List of measured children
    val placeables = measurables.mapIndexed { index, measurable ->

        // Measure each child
        val placeable = measurable.measure(constraints)

        // Track the width and max height of each row
        val row = index % rows
        rowWidths[row] += placeable.width
        rowHeights[row] = Math.max(rowHeights[row], placeable.height)

        placeable
    }
    ...
}

Nous disposons à présent de la liste des éléments enfants mesurés dans notre logique. Avant de les positionner sur l'écran, nous devons calculer la taille de notre grille (width et height complètes). Puisque nous connaissons déjà la hauteur maximale de chaque ligne, nous pouvons calculer l'emplacement des éléments de chaque ligne dans la position Y. Nous allons enregistrer les positions Y dans la variable rowY :

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Grid's width is the widest row
    val width = rowWidths.maxOrNull()
        ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

    // Grid's height is the sum of the tallest element of each row
    // coerced to the height constraints
    val height = rowHeights.sumOf { it }
        .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

    // Y of each row, based on the height accumulation of previous rows
    val rowY = IntArray(rows) { 0 }
    for (i in 1 until rows) {
        rowY[i] = rowY[i-1] + rowHeights[i-1]
    }

    ...
}

Pour terminer, nous allons positionner nos éléments enfants sur l'écran en appelant placeable.placeRelative(x, y). Dans ce cas d'utilisation, nous allons également effectuer le suivi de la coordonnée X pour chaque ligne dans la variable rowX :

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Set the size of the parent layout
    layout(width, height) {
        // x cord we have placed up to, per row
        val rowX = IntArray(rows) { 0 }

        placeables.forEachIndexed { index, placeable ->
            val row = index % rows
            placeable.placeRelative(
                x = rowX[row],
                y = rowY[row]
            )
            rowX[row] += placeable.width
        }
    }
}

Utilisation de la StaggeredGrid personnalisée dans un exemple

Maintenant que nous disposons d'une mise en page de grille personnalisée capable de mesurer et de positionner les éléments enfants, nous allons l'utiliser dans notre application. Pour simuler les chips de l'application Owl dans la grille, nous pouvons créer facilement un composable qui effectue une opération similaire :

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier.size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

Avec l'aperçu :

f1f8c6bb7f12cf1.png

Nous allons maintenant créer une liste de rubriques à présenter dans notre BodyContent et les afficher dans StaggeredGrid :

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        BodyContent()
    }
}

Avec l'aperçu :

e9861768e4e27dd4.png

Comme vous pouvez le voir, il est possible de modifier le nombre de lignes de notre grille sans que cela n'affecte le fonctionnement :

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier, rows = 5) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

Avec l'aperçu :

555f88fd41e4dff4.png

En fonction du nombre de lignes, nos rubriques peuvent sortir de l'écran. Nous pouvons donc faire en sorte que BodyContent soit un élément déroulant en encapsulant simplement StaggeredGrid dans un composable Row déroulant et en lui transmettant le modificateur plutôt qu'à StaggeredGrid.

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier.horizontalScroll(rememberScrollState())) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

Si vous utilisez le bouton Interactive Preview bb4c8dfe4b8debaa.png ou exécutez l'application sur l'appareil en appuyant sur le bouton d'exécution d'Android Studio, vous voyez comment faire défiler le contenu horizontalement.

9. Détails techniques des modificateurs de mise en page

Maintenant que vous connaissez les principes de base des modificateurs, et que vous savez comment créer des composables personnalisés, mais aussi mesurer et positionner manuellement des éléments enfants, vous avez une vision plus claire du fonctionnement interne des modificateurs.

En bref, les modificateurs vous permettent de personnaliser le comportement d'un composable. Vous pouvez combiner plusieurs modificateurs en les enchaînant. Il existe de nombreux types de modificateurs. Cependant, dans cette section, nous nous focaliserons sur les interfaces LayoutModifier, étant donné qu'elles peuvent modifier la façon dont un composant d'UI est mesuré et mis en page.

Les composables sont responsables de leur propre contenu qui ne peut être inspecté ou manipulé par un élément parent que si leur auteur expose une API explicite permettant de le faire. De même, les modificateurs d'un composable "décorent" ce qu'ils modifient avec la même opacité : les modificateurs sont encapsulés.

Analyse d'un modificateur

Modifier et LayoutModifier sont des interfaces publiques. Vous pouvez donc créer vos propres modificateurs. Comme nous avons utilisé Modifier.padding précédemment, analysons à présent son implémentation afin de mieux comprendre les modificateurs.

padding est une fonction sauvegardée par une classe qui implémente l'interface LayoutModifier et qui remplacera la méthode measure. PaddingModifier est une classe standard qui implémente equals(), de sorte que le modificateur puisse être comparé entre les différentes recompositions.

À titre d'exemple, voici le code source du modificateur padding qui modifie la taille et les contraintes de l'élément auquel il est appliqué :

// How to create a modifier
@Stable
fun Modifier.padding(all: Dp) =
    this.then(
        PaddingModifier(start = all, top = all, end = all, bottom = all, rtlAware = true)
    )

// Implementation detail
private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp,
    val rtlAware: Boolean,
) : LayoutModifier {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
            } else {
                placeable.place(start.roundToPx(), top.roundToPx())
            }
        }
    }
}

La nouvelle width de l'élément sera la width de l'élément enfant + les valeurs de marge de début et de fin appliquées de force aux contraintes de largeur de l'élément. La height sera la height de l'élément enfant + les valeurs de marge supérieure et inférieure appliquées de force aux contraintes de hauteur de l'élément.

De l'importance de l'ordre des modificateurs

Comme nous l'avons vu dans la première section, l'ordre a de l'importance lors du chaînage des modificateurs, car ceux-ci sont appliqués au composable qu'ils modifient, du plus ancien au plus récent, ce qui signifie que la mesure et la mise en page des modificateurs de gauche auront une incidence sur le modificateur de droite. La taille finale du composable dépend de tous les modificateurs transmis en tant que paramètre.

Tout d'abord, les modificateurs mettent à jour les contraintes de gauche à droite, puis ils renvoient la taille de droite à gauche. Illustrons cela à l'aide d'un exemple :

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray)
            .size(200.dp)
            .padding(16.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

Les modificateurs appliqués de cette façon génèrent cet aperçu :

cb209bb5edf634d6.png

Nous allons d'abord modifier l'arrière-plan pour voir comment les modificateurs affectent l'UI. Ensuite, nous allons limiter la taille pour avoir 200.dp comme width et comme height. Pour terminer, nous allons appliquer une marge intérieure pour ajouter de l'espace entre le texte et les éléments environnants.

Étant donné que les contraintes sont propagées dans la chaîne de gauche à droite, les contraintes avec lesquelles le contenu du composable Row doit être mesuré sont (200-16-16)=168 dp pour les valeurs width et height minimales et maximales. Cela signifie que la taille de StaggeredGrid sera exactement de 168x168 dp. Par conséquent, la taille finale du composable Row déroulant, après avoir exécuté la chaîne modifySize de droite à gauche, sera de 200x200 dp.

Si l'on change l'ordre des modificateurs afin d'appliquer la marge intérieure, puis la taille, l'UI obtenue est différente :

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray, shape = RectangleShape)
            .padding(16.dp)
            .size(200.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

Avec l'aperçu :

17da5805d6d8fc91.png

Dans ce cas, les contraintes d'origine des éléments Row et padding déroulants seront forcées sur les contraintes size afin de mesurer les éléments enfants. Par conséquent, StaggeredGrid sera limité sur 200 dp pour les valeurs width et height minimales et maximales. La taille de StaggeredGrid est de 200x200 dp. Puisque la taille est modifiée de droite à gauche, le modificateur padding l'incrémentera sur (200+16+16)x(200+16+16)=232x232, qui correspondra également à la taille finale de Row.

Orientation de la mise en page

Vous pouvez modifier l'orientation de la mise en page d'un composable à l'aide de la propriété ambiante LayoutDirection.

Si vous positionnez des composables manuellement sur l'écran, layoutDirection fait partie du LayoutScope du modificateur layout ou du composable Layout. Si vous utilisez layoutDirection, positionnez les composables à l'aide de place car, contrairement à la méthode placeRelative, il ne met pas automatiquement en miroir la position dans un contexte "droite-gauche".

Code complet de cette section

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
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.layout.Layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.codelab.layouts.ui.LayoutsCodelabTheme
import kotlin.math.max

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier
        .background(color = Color.LightGray)
        .padding(16.dp)
        .size(200.dp)
        .horizontalScroll(rememberScrollState()),
        content = {
            StaggeredGrid {
                for (topic in topics) {
                    Chip(modifier = Modifier.padding(8.dp), text = topic)
                }
            }
        })
}

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Keep track of the width of each row
        val rowWidths = IntArray(rows) { 0 }

        // Keep track of the max height of each row
        val rowHeights = IntArray(rows) { 0 }

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.mapIndexed { index, measurable ->
            // Measure each child
            val placeable = measurable.measure(constraints)

            // Track the width and max height of each row
            val row = index % rows
            rowWidths[row] += placeable.width
            rowHeights[row] = Math.max(rowHeights[row], placeable.height)

            placeable
        }

        // Grid's width is the widest row
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

        // Grid's height is the sum of the tallest element of each row
        // coerced to the height constraints
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

        // Y of each row, based on the height accumulation of previous rows
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // Set the size of the parent layout
        layout(width, height) {
            // x co-ord we have placed up to, per row
            val rowX = IntArray(rows) { 0 }

            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    x = rowX[row],
                    y = rowY[row]
                )
                rowX[row] += placeable.width
            }
        }
    }
}

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

10. ConstraintLayout

ConstraintLayout peut vous aider à positionner des composables par rapport à d'autres sur l'écran. Il s'agit d'une alternative à l'utilisation de plusieurs éléments Row, Column et Box. ConstraintLayout s'avère particulièrement utile pour implémenter des mises en page de grande taille qui présentent des exigences plus complexes en termes d'alignement.

Vous trouverez la dépendance Constraint Layout de Compose dans le fichier build.gradle de votre projet :

// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"

Dans Compose, ConstraintLayout fonctionne avec un langage spécifique au domaine (DSL) :

  • Les références sont créées à l'aide de createRefs() (ou createRef()) et une référence doit être associée à chaque composable de ConstraintLayout.
  • Les contraintes sont fournies à l'aide du modificateur constrainAs qui utilise la référence comme paramètre et vous permet de spécifier ses contraintes dans le lambda "body".
  • Les contraintes sont spécifiées à l'aide de linkTo ou d'autres méthodes utiles.
  • parent est une référence existante qui peut être utilisée pour spécifier des contraintes par rapport au composable ConstraintLayout proprement dit.

Prenons un exemple simple :

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {

        // Create references for the composables to constrain
        val (button, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            // Assign reference "button" to the Button composable
            // and constrain it to the top of the ConstraintLayout
            modifier = Modifier.constrainAs(button) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button")
        }

        // Assign reference "text" to the Text composable
        // and constrain it to the bottom of the Button composable
        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
        })
    }
}

@Preview
@Composable
fun ConstraintLayoutContentPreview() {
    LayoutsCodelabTheme {
        ConstraintLayoutContent()
    }
}

Le haut du Button est limité à l'élément parent avec une marge de 16.dp et un Text est limité sur le bas du Button, également avec une marge de 16.dp.

72fcb81ab2c0483c.png

Si vous souhaitez centrer le texte horizontalement, vous pouvez utiliser la fonction centerHorizontallyTo qui définit le start et la end du Text sur les bords de parent :

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        ... // Same as before

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
            // Centers Text horizontally in the ConstraintLayout
            centerHorizontallyTo(parent)
        })
    }
}

Avec l'aperçu :

729a1b4c03f1f187.png

La taille de ConstraintLayout doit être la plus petite possible afin d'encapsuler son contenu. C'est pourquoi on a l'impression que Text est centré autour de Button plutôt qu'autour du parent. Si vous souhaitez utiliser un autre comportement de dimensionnement, d'autres modificateurs de dimensionnement (fillMaxSize et size, par exemple) doivent être appliqués au composable ConstraintLayout, comme avec toute autre mise en page dans Compose.

Assistants

Le langage DSL prend également en charge la création de guides, de barrières et de chaînes. Exemple :

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // Creates references for the three composables
        // in the ConstraintLayout's body
        val (button1, button2, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button1) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button 1")
        }

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button1.bottom, margin = 16.dp)
            centerAround(button1.end)
        })

        val barrier = createEndBarrier(button1, text)
        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button2) {
                top.linkTo(parent.top, margin = 16.dp)
                start.linkTo(barrier)
            }
        ) {
            Text("Button 2")
        }
    }
}

Avec l'aperçu :

a4117576ef1768a2.png

Notez que

  • des barrières (ainsi que tous les autres assistants) peuvent être créées dans le corps de ConstraintLayout, mais pas à l'intérieur de constrainAs ;
  • linkTo peut être utilisé pour appliquer des limites avec des guides et des barrières de la même manière que cela fonctionne pour les bords de mises en page.

Personnaliser les dimensions

Par défaut, les éléments enfants de ConstraintLayout sont autorisés à choisir la taille dont ils ont besoin pour encapsuler leur contenu. Par exemple, cela signifie qu'un Text peut sortir des limites de l'écran si le texte est trop long :

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(fraction = 0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(start = guideline, end = parent.end)
            }
        )
    }
}

@Preview
@Composable
fun LargeConstraintLayoutPreview() {
    LayoutsCodelabTheme {
        LargeConstraintLayout()
    }
}

616c19b971811cfa.png

Bien évidemment, vous aimeriez qu'un saut de ligne soit ajouté dans l'espace disponible. Pour obtenir ce résultat, vous pouvez modifier le comportement width du texte :

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(guideline, parent.end)
                width = Dimension.preferredWrapContent
            }
        )
    }
}

Avec l'aperçu :

fc41cacd547bbea.png

Les comportements Dimension disponibles sont les suivants :

  • preferredWrapContent : mise en page avec encapsulation du contenu, en tenant compte des contraintes de cette dimension.
  • wrapContent : mise en page avec encapsulation du contenu, même si les contraintes ne l'autorisent pas.
  • fillToConstraints : la mise en page est développée afin de remplir l'espace défini par ses contraintes dans cette dimension.
  • preferredValue : la mise en page est une valeur dp fixe, en tenant compte des contraintes de cette dimension.
  • value : la mise en page est une valeur dp fixe, quelles que soient les contraintes de cette dimension.

Ajoutons également que certaines Dimensions peuvent être forcées :

width = Dimension.preferredWrapContent.atLeast(100.dp)

API dissociée

Jusqu'à présent, les contraintes utilisées dans les exemples ont été spécifiées en ligne, avec un modificateur dans le composable auquel elles s'appliquent. Cependant, dans certains cas, il est utile de dissocier les contraintes des mises en page auxquelles elles s'appliquent : c'est par exemple le cas pour modifier facilement les contraintes en fonction de la configuration de l'écran ou pour effectuer une animation entre deux ensembles de contraintes.

En pareils cas, vous pouvez utiliser ConstraintLayout d'une autre manière :

  1. Transmettez un ConstraintSet en tant que paramètre à ConstraintLayout.
  2. Attribuez des références créées dans le ConstraintSet à des composables à l'aide du modificateur layoutId.

Cette forme d'API appliquée au premier exemple ConstraintLayout illustré ci-dessus, optimisé pour la largeur de l'écran, se présente comme suit :

@Composable
fun DecoupledConstraintLayout() {
    BoxWithConstraints {
        val constraints = if (maxWidth < maxHeight) {
            decoupledConstraints(margin = 16.dp) // Portrait constraints
        } else {
            decoupledConstraints(margin = 32.dp) // Landscape constraints
        }

        ConstraintLayout(constraints) {
            Button(
                onClick = { /* Do something */ },
                modifier = Modifier.layoutId("button")
            ) {
                Text("Button")
            }

            Text("Text", Modifier.layoutId("text"))
        }
    }
}

private fun decoupledConstraints(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")

        constrain(button) {
            top.linkTo(parent.top, margin= margin)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
        }
    }
}

11. Fonctionnalités intrinsèques

L'une des règles de Compose est de ne mesurer vos éléments enfants qu'une seule fois ; si vous les mesurez deux fois, une exception d'exécution est générée. Toutefois, il arrive que vous ayez besoin d'informations sur vos éléments enfants avant de les mesurer.

Les fonctionnalités intrinsèques vous permettent d'interroger des éléments enfants avant qu'ils ne soient réellement mesurés.

Dans le cas d'un composable, vous pouvez demander son intrinsicWidth ou intrinsicHeight :

  • (min|max)IntrinsicWidth : compte tenu de cette hauteur, quelle est la largeur minimale/maximale de votre contenu que vous pouvez peindre correctement.
  • (min|max)IntrinsicHeight : compte tenu de cette largeur, quelle est la hauteur minimale/maximale de votre contenu que vous pouvez peindre correctement.

Par exemple, si vous demandez la minIntrinsicHeight d'un Text avec une width infinie, la height du Text est renvoyée comme si le texte était dessiné dans une seule ligne.

Fonctionnalités intrinsèques en action

Supposons que vous souhaitiez créer un composable qui affiche à l'écran deux éléments textuels séparés comme ceci :

835f0b8c9f07cd9.png

Comment allez-vous procéder ? Il peut y avoir un Row contenant deux éléments Text dont l'extension est maximale et, au milieu, un Divider. Le séparateur doit être fin (width = 1.dp) et aussi grand que l'élément Text le plus grand.

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),

            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

En affichant l'aperçu, on constate que le séparateur s'étend sur toute la hauteur de l'écran. Ce n'est pas ce qui était prévu !

d61f179394ded825.png

Cela est dû au fait que Row mesure chaque élément enfant séparément et que la hauteur de Text ne peut pas être utilisée pour limiter le Divider. L'objectif est que Divider remplisse l'espace disponible avec une hauteur donnée. Pour cela, vous pouvez utiliser le modificateur height(IntrinsicSize.Min).

height(IntrinsicSize.Min) dimensionne ses éléments enfants en les forçant à être aussi grands que leur hauteur intrinsèque minimale. Compte tenu de sa nature récursive, il va interroger Row et la minIntrinsicHeight de ses éléments enfants.

Si l'on applique cela à notre code, on obtiendra le résultat attendu.

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),
            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

Avec l'aperçu :

835f0b8c9f07cd9.png

La minIntrinsicHeight de Row sera la minIntrinsicHeight maximale de ses éléments enfants. La minIntrinsicHeight de Divider est 0, car elle n'occupe pas l'espace si aucune contrainte n'est fournie ; la minIntrinsicHeight de Text correspondra à celle du texte auquel une width spécifique est donnée. Par conséquent, la height de Row sera la minIntrinsicHeight maximale des éléments Text. Divider va alors étendre sa height en fonction de la contrainte height fournie par Row.

DIY

Lorsque vous créez une mise en page personnalisée, vous pouvez modifier la méthode de calcul des fonctionnalités intrinsèques avec la (min|max)Intrinsic(Width|Height) de l'interface MeasurePolicy. Cependant, dans la plupart des cas, les paramètres par défaut devraient s'avérer suffisants.

Vous pouvez aussi modifier les fonctionnalités intrinsèques à l'aide de modificateurs qui remplacent les méthodes Density.(min|max)Intrinsic(Width|Height)Of de l'interface Modifier, lesquelles comportent également un paramètre par défaut tout à fait valable.

12. Félicitations

Bravo ! Vous êtes arrivé au terme de cet atelier de programmation.

Solution de l'atelier de programmation

Le code nécessaire à la solution de cet atelier de programmation est disponible sur GitHub :

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

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

Et maintenant ?

Consultez les autres ateliers de programmation du parcours Compose :

Complément d'informations

Exemples d'applications

  • Création de mises en page personnalisées dans Owl
  • Affichage de graphiques et de tableaux dans Rally
  • Jetsnack avec des mises en page personnalisées