Listes et grilles

De nombreuses applications affichent des collections d'éléments. Cette présentation vous explique comment effectuer cette opération efficacement dans Jetpack Compose.

Si vous savez que votre cas d'utilisation ne nécessite pas de défilement, vous pouvez utiliser une simple Column ou une Row (en fonction de la direction) et émettre le contenu de chaque élément en effectuant une itération sur une liste comme suit:

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}

Nous pouvons mettre en place une Column déroulante en utilisant le modificateur verticalScroll().

Listes différées

Si vous devez afficher un grand nombre d'éléments (ou une liste d'une longueur indéterminée), l'utilisation d'une mise en page de type Column peut entraîner des problèmes de performances, car tous les éléments seront composés et affichés, qu'ils soient visibles ou non.

Compose fournit un ensemble de composants qui composent et mettent en page uniquement les éléments visibles dans la fenêtre d'affichage du composant. Ces composants incluent LazyColumn et LazyRow.

Comme leur nom l'indique, la différence entre LazyColumn et LazyRow réside dans l'orientation qu'elles imposent aux éléments et au défilement. LazyColumn génère une liste à défilement vertical, et LazyRow génère une liste à défilement horizontal.

Les composants inactifs diffèrent de la plupart des mises en page dans Compose. Au lieu d'accepter un paramètre de bloc de contenu @Composable, qui permet aux applications d'émettre directement des composables, les composants inactifs fournissent un bloc LazyListScope.(). Ce bloc LazyListScope propose un DSL qui permet aux applications de décrire les contenus d'un élément. Le composant différé est ensuite chargé d'ajouter le contenu de chaque élément selon la mise en page et la position de défilement définies.

LazyListScope DSL

Le DSL de LazyListScope fournit un certain nombre de fonctions permettant de décrire les éléments de la mise en page. En premier lieu, item() ajoute un seul élément, et items(Int) ajoute plusieurs éléments :

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

De plus, de nombreuses fonctions d'extension vous permettent d'ajouter des collections d'éléments, comme List. Ces extensions nous permettent de migrer facilement l'exemple Column ci-dessus :

/**
 * import androidx.compose.foundation.lazy.items
 */
LazyColumn {
    items(messages) { message ->
        MessageRow(message)
    }
}

Il existe également une variante de la fonction d'extension items(), appelée itemsIndexed(), qui fournit l'index. Pour en savoir plus, consultez la documentation de référence sur LazyListScope.

Grilles différées

Les composables LazyVerticalGrid et LazyHorizontalGrid permettent d'afficher des éléments dans une grille. Une grille verticale différée affiche ses éléments dans un conteneur à faire défiler verticalement, s'étendant sur plusieurs colonnes, tandis que les grilles horizontales différées ont le même comportement sur l'axe horizontal.

Les grilles ont les mêmes capacités d'API puissantes que les listes et utilisent également un DSL très similaire (LazyGridScope.()) pour décrire le contenu.

Capture d&#39;écran d&#39;un téléphone qui affiche une grille de photos.

Les paramètres columns dans LazyVerticalGrid et rows dans LazyHorizontalGrid contrôlent la manière dont les cellules deviennent des colonnes ou des lignes. L'exemple suivant affiche des éléments dans une grille, en utilisant GridCells.Adaptive pour définir chaque colonne sur une largeur d'au moins 128.dp :

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 128.dp)
) {
    items(photos) { photo ->
        PhotoItem(photo)
    }
}

LazyVerticalGrid permet de spécifier la largeur des éléments. La grille peut ainsi contenir autant de colonnes que possible. Une fois le nombre de colonnes calculé, toute largeur restante est répartie de manière égale entre les colonnes. Cette méthode de dimensionnement adaptative est particulièrement utile pour afficher des ensembles d'éléments sur différentes tailles d'écran.

Si vous connaissez le nombre exact de colonnes à utiliser, vous pouvez fournir une instance de GridCells.Fixed contenant le nombre de colonnes requises.

Lorsque certains éléments ont des dimensions non standards, vous pouvez utiliser la grille pour définir des intervalles de colonnes personnalisées pour vos éléments. Spécifiez l'intervalle de colonne avec le paramètre span des méthodes LazyGridScope DSL item et items. maxLineSpan, l'une des valeurs du champ d'application de la portée, est particulièrement utile lorsque vous utilisez le dimensionnement adaptatif, car le nombre de colonnes n'est pas fixe. Cet exemple montre comment fournir un intervalle de lignes complet :

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 30.dp)
) {
    item(span = {
        // LazyGridItemSpanScope:
        // maxLineSpan
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
    // ...
}

Grille décalée différée

LazyVerticalStaggeredGrid et LazyHorizontalStaggeredGrid sont des composables qui vous permettent de créer une grille d'éléments décalée et chargée de manière différée. Une grille verticale décalée différée affiche ses éléments dans un conteneur à faire défiler verticalement qui s'étend sur plusieurs colonnes et permet aux éléments individuels d'avoir des hauteurs différentes. Les grilles horizontales différées ont le même comportement sur l'axe horizontal avec des éléments de différentes largeurs.

L'extrait de code suivant est un exemple de base d'utilisation de LazyVerticalStaggeredGrid avec une largeur 200.dp par élément:

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Adaptive(200.dp),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

Figure 1. Exemple de grille verticale décalée avec lazy loading

Pour définir un nombre fixe de colonnes, vous pouvez utiliser StaggeredGridCells.Fixed(columns) au lieu de StaggeredGridCells.Adaptive. La largeur disponible est alors divisée par le nombre de colonnes (ou de lignes pour une grille horizontale), et chaque élément occupe cette largeur (ou cette hauteur pour une grille horizontale):

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Fixed(3),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

Grille décalée différée d&#39;images dans Compose
Figure 2. Exemple de grille verticale décalée avec colonnes fixes

Marge intérieure du contenu

Vous devrez parfois ajouter une marge intérieure aux bords du contenu. Pour ce faire, les composants à chargement différé vous permettent de transmettre un élément PaddingValues au paramètre contentPadding :

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}

Dans cet exemple, nous ajoutons 16.dp de marge intérieure sur les bords horizontaux (à gauche et à droite), puis 8.dp en haut et en bas du contenu.

Notez que cette marge intérieure est appliquée au contenu, et non à la LazyColumn elle-même. Dans l'exemple ci-dessus, le premier élément ajoutera une marge intérieure de 8.dp en haut, le dernier élément ajoutera 8.dp en bas, et une marge de 16.dp sera ajoutée à tous les éléments à gauche et à droite.

Espacement du contenu

Pour ajouter un espace entre les éléments, vous pouvez utiliser Arrangement.spacedBy(). L'exemple ci-dessous ajoute 4.dp d'espace entre chaque élément :

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

De même pour LazyRow :

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

Les grilles acceptent les configurations verticales et horizontales :

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item ->
        PhotoItem(item)
    }
}

Clés d'élément

Par défaut, l'état de chaque élément pointe vers sa position dans la liste ou la grille. Toutefois, cela peut entraîner des problèmes en cas de modification de l'ensemble de données, car les éléments qui changent de position perdent tout état mémorisé. Imaginons le scénario suivant : un LazyRow se trouve dans une LazyColumn. Si la ligne change de position, l'utilisateur perd alors sa position de défilement dans la ligne.

Pour résoudre ce problème, vous pouvez fournir une clé stable et unique pour chaque élément, en bloquant le paramètre key. Fournir une clé stable permet d'assurer la cohérence de l'état des éléments pour toutes les modifications de l'ensemble de données :

LazyColumn {
    items(
        items = messages,
        key = { message ->
            // Return a stable + unique key for the item
            message.id
        }
    ) { message ->
        MessageRow(message)
    }
}

En fournissant des clés, vous aidez Compose à gérer les réorganisations correctement. Par exemple, si votre élément contient un état mémorisé, les clés permettent à Compose de déplacer cet état avec l'élément lorsque sa position change.

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = remember {
            Random.nextInt()
        }
    }
}

Cependant, les types de clés d'élément que vous pouvez utiliser sont limités. Le type de clé doit être compatible avec Bundle, le mécanisme d'Android permettant de conserver les états lorsque l'activité est recréée. Bundle prend en charge les primitives, les énumérations, ou encore les parcelables.

LazyColumn {
    items(books, key = {
        // primitives, enums, Parcelable, etc.
    }) {
        // ...
    }
}

La clé doit être compatible avec Bundle pour que le rememberSaveable à l'intérieur du composable de l'élément puisse être restauré lors de la recréation de l'activité, ou même lorsque vous faites défiler la page hors de cet élément et vers l'arrière.

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = rememberSaveable {
            Random.nextInt()
        }
    }
}

Animation des éléments

Si vous avez déjà utilisé le widget RecyclerView, vous savez qu'il anime les modifications d'éléments automatiquement. Les mises en page différées offrent les mêmes fonctionnalités pour réorganiser les éléments. Le fonctionnement de l'API est simple : il vous suffit de définir le modificateur animateItem sur le contenu de l'élément :

LazyColumn {
    // It is important to provide a key to each item to ensure animateItem() works as expected.
    items(books, key = { it.id }) {
        Row(Modifier.animateItem()) {
            // ...
        }
    }
}

Si nécessaire, vous pouvez même fournir des spécifications d'animations personnalisées :

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItem(
                fadeInSpec = tween(durationMillis = 250),
                fadeOutSpec = tween(durationMillis = 100),
                placementSpec = spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy)
            )
        ) {
            // ...
        }
    }
}

Assurez-vous de fournir des clés pour vos éléments afin de pouvoir trouver la nouvelle position de l'élément déplacé.

En-têtes persistants (expérimental)

Le format "en-tête persistant" est utile pour afficher des listes de données groupées. Vous trouverez ci-dessous un exemple de "liste de contacts", regroupés par leur initiale :

Vidéo d&#39;un téléphone faisant défiler une liste de contacts vers le haut ou vers le bas.

Pour obtenir un en-tête persistant avec LazyColumn, vous pouvez utiliser la fonction expérimentale stickyHeader(), qui fournit le contenu de l'en-tête :

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}

Pour obtenir une liste avec plusieurs en-têtes, comme dans l'exemple "Liste de contacts" ci-dessus, vous pouvez procéder comme suit :

// This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

Réagir à la position de défilement

De nombreuses applications doivent réagir et écouter la position de défilement et les changements au niveau de la mise en page des éléments. Les composants différés prennent en charge ce cas d'utilisation en hissant LazyListState :

@Composable
fun MessageList(messages: List<Message>) {
    // Remember our own LazyListState
    val listState = rememberLazyListState()

    // Provide it to LazyColumn
    LazyColumn(state = listState) {
        // ...
    }
}

Pour les cas d'utilisation simples, les applications ont besoin uniquement de connaître le premier élément visible. LazyListState fournit donc firstVisibleItemIndex et firstVisibleItemScrollOffset.

Prenons l'exemple de l'affichage et du masquage d'un bouton selon que l'utilisateur a fait défiler la page au-delà du premier élément :

@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

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

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

Lire l'état directement dans la composition est utile lorsque vous devez mettre à jour d'autres composables d'UI, mais dans certains cas, l'événement n'a pas besoin d'être traité dans la même composition. Par exemple, lors de l'envoi d'un événement analytics une fois que l'utilisateur a fait défiler la page au-delà d'un certain point. Pour gérer efficacement ce cas de figure, nous pouvons utiliser snapshotFlow() :

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

LazyListState fournit également des informations sur tous les éléments affichés actuellement et leurs contours à l'écran, via la propriété layoutInfo. Pour en savoir plus, consultez LazyListLayoutInfo.

Contrôler la position de défilement

En plus de réagir à la position de défilement, les applications peuvent aussi contrôler la position de défilement. LazyListState vous permet d'effectuer cette action via la fonction scrollToItem(), qui enregistre "immédiatement" la position de défilement, et animateScrollToItem(), qui fait défiler une animation (également appelée défilement fluide) :

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}

Ensembles de données volumineux (pagination)

La bibliothèque Paging permet aux applications de prendre en charge des listes d'éléments étendues, de charger et d'afficher de petits segments de la liste si nécessaire. Paging 3.0 et ses versions ultérieures sont compatibles avec Compose via la bibliothèque androidx.paging:paging-compose.

Pour afficher une liste de contenu paginé, nous pouvons utiliser la fonction d'extension collectAsLazyPagingItems(), puis transmettre les valeurs LazyPagingItems à items() dans notre LazyColumn. Comme pour la pagination dans les vues, vous pouvez afficher des espaces réservés pendant le chargement des données en vérifiant si item est null :

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it.id }
        ) { index ->
            val message = lazyPagingItems[index]
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

Conseils pour utiliser les mises en page différées

Voici quelques conseils pour vous assurer que vos mises en page différées fonctionnent comme prévu.

Évitez d'utiliser des éléments de taille 0 px

Cela peut se produire lorsque, par exemple, vous prévoyez de récupérer de manière asynchrone certaines données telles que des images afin de remplir les éléments de votre liste ultérieurement. La mise en page différée pourrait ainsi composer tous ses éléments dans la première mesure, car leur hauteur est de 0 pixel et tous les éléments pourraient s'afficher dans la fenêtre d'affichage. Une fois les éléments chargés et leur hauteur développée, les mises en page différées suppriment tous les autres éléments qui ont été composés inutilement la première fois, car ils ne peuvent en fait pas être placés dans la fenêtre d'affichage. Pour éviter cela, vous devez définir la taille par défaut de vos éléments, de sorte que la mise en page différée puisse calculer correctement le nombre d'éléments pouvant tenir dans la fenêtre d'affichage :

@Composable
fun Item(imageUrl: String) {
    AsyncImage(
        model = rememberAsyncImagePainter(model = imageUrl),
        modifier = Modifier.size(30.dp),
        contentDescription = null
        // ...
    )
}

Lorsque vous connaissez la taille approximative de vos éléments après le chargement asynchrone des données, nous vous recommandons de vous assurer que leur taille reste la même avant et après le chargement, par exemple en ajoutant des espaces réservés. Cela permettra de conserver la bonne position de défilement.

Éviter l'imbrication de composants à faire défiler dans la même direction

Cela s'applique uniquement aux cas d'imbrication d'enfants à défilement sans taille prédéfinie dans un autre parent à défilement parallèle. Par exemple, en essayant d'imbriquer un LazyColumn enfant sans hauteur fixe dans un parent Column à défilement vertical :

// throws IllegalStateException
Column(
    modifier = Modifier.verticalScroll(state)
) {
    LazyColumn {
        // ...
    }
}

Vous pouvez obtenir le même résultat en encapsulant tous vos composables dans un LazyColumn parent et en utilisant son DSL pour transmettre différents types de contenus. Ainsi, vous n'émettez qu'un seul élément et plusieurs éléments de liste au même endroit :

LazyColumn {
    item {
        Header()
    }
    items(data) { item ->
        PhotoItem(item)
    }
    item {
        Footer()
    }
}

N'oubliez pas que les cas où vous imbriquez des mises en page avec des directions différentes, par exemple un parent Row déroulant et un LazyColumn enfant sont autorisés :

Row(
    modifier = Modifier.horizontalScroll(scrollState)
) {
    LazyColumn {
        // ...
    }
}

Il en est de même pour les cas où vous utilisez toujours les mêmes mises en page dans la même direction, mais où vous définissez également une taille fixe sur les éléments enfants imbriqués :

Column(
    modifier = Modifier.verticalScroll(scrollState)
) {
    LazyColumn(
        modifier = Modifier.height(200.dp)
    ) {
        // ...
    }
}

Évitez de placer plusieurs éléments dans un élément

Dans cet exemple, le deuxième élément lambda émet deux éléments dans un bloc :

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Item(2)
    }
    item { Item(3) }
    // ...
}

Les mises en page différées gèrent ce processus comme prévu : les éléments sont disposés les uns après les autres, comme s'il s'agissait d'éléments différents. Toutefois, cela pose quelques problèmes.

Lorsque plusieurs éléments sont émis pour un même élément, ils sont traités comme une seule entité, ce qui signifie qu'ils ne peuvent plus être composés individuellement. Si un élément devient visible à l'écran, tous les éléments correspondant à l'élément doivent être composés et mesurés. Cependant, une utilisation excessive peut nuire aux performances. Un regroupement de tous les éléments dans un seul et même élément irait à l'encontre de l'objectif de la mise en page différée. Hormis les problèmes de performances potentiels, le fait d'insérer davantage d'éléments dans un élément peut également interférer avec scrollToItem() et animateScrollToItem().

Toutefois, il existe des cas d'utilisation valides pour placer plusieurs éléments dans un même élément, comme des séparateurs dans une liste. Les séparateurs ne doivent pas être des indicateurs de défilement, car ils ne doivent pas être considérés comme des éléments indépendants. De plus, les séparateurs sont petits, donc les performances ne seront pas affectées. Un séparateur devra probablement être visible lorsque l'élément qui le précède est visible. Il pourra ainsi appartenir à l'élément précédent :

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Divider()
    }
    item { Item(2) }
    // ...
}

Envisagez d'utiliser des dispositions personnalisées

Généralement, les listes différées comportent de nombreux éléments et occupent plus d'espace que le conteneur à défilement. Cependant, lorsque votre liste contient peu d'éléments, votre design peut avoir des exigences plus spécifiques quant à leur positionnement dans la fenêtre d'affichage.

Pour ce faire, vous pouvez utiliser l'élément Arrangement personnalisé et le transmettre à LazyColumn. Dans l'exemple suivant, l'objet TopWithFooter doit se contenter d'implémenter la méthode arrange. Tout d'abord, il dispose les éléments les uns après les autres. Ensuite, si la hauteur totale utilisée est inférieure à la hauteur de la fenêtre d'affichage, le pied de page sera placé en bas :

object TopWithFooter : Arrangement.Vertical {
    override fun Density.arrange(
        totalSize: Int,
        sizes: IntArray,
        outPositions: IntArray
    ) {
        var y = 0
        sizes.forEachIndexed { index, size ->
            outPositions[index] = y
            y += size
        }
        if (y < totalSize) {
            val lastIndex = outPositions.lastIndex
            outPositions[lastIndex] = totalSize - sizes.last()
        }
    }
}

Envisagez d'ajouter contentType

À partir de la version 1.2 de Compose, pensez à ajouter contentType à vos listes ou grilles afin d'optimiser les performances de votre mise en page différée. Cela vous permet de spécifier le type de contenu pour chaque élément de la mise en page lorsque vous composez une liste ou une grille composée de plusieurs types d'éléments :

LazyColumn {
    items(elements, contentType = { it.type }) {
        // ...
    }
}

Lorsque vous fournissez l'élément contentType, Compose ne peut réutiliser les compositions qu'entre les éléments du même type. La réutilisation est plus efficace lorsque vous composez des éléments de structure similaire. En fournissant les types de contenu, vous vous assurez que Compose n'essaie pas de composer un élément de type A au-dessus d'un élément complètement différent de type B. Ainsi, vous tirerez davantage parti de la réutilisation des compositions et optimiserez les performances de vos mises en page différées.

Évaluation des performances

Vous ne pouvez mesurer de manière fiable les performances d'une mise en page différée que lorsque vous utilisez le mode de publication et que l'optimisation R8 est activée. Sur les versions de débogage, le défilement différé de la mise en page peut sembler plus lent. Pour en savoir plus, consultez la page Performances de Compose.