Listas e grades

Muitos apps precisam mostrar coleções de itens. Este documento explica como fazer isso de forma eficiente no Jetpack Compose.

Se você sabe que seu caso de uso não requer rolagem, use uma Column ou Row simples (dependendo da direção) e emita o conteúdo de cada item iterando em uma lista da seguinte maneira:

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

É possível permitir a rolagem de Column usando o modificador verticalScroll().

Listas lentas

Se você precisar exibir muitos itens (ou uma lista de tamanho desconhecido), usar um layout como uma Column pode causar problemas de performance, já que todos os itens vão ser compostos e colocados no layout independente de estarem visíveis ou não.

O Compose fornece um conjunto de componentes para compor e posicionar apenas os itens que estão visíveis na janela de visualização. Esses componentes incluem LazyColumn e LazyRow.

Como o nome sugere, a diferença entre LazyColumn e LazyRow é a orientação da disposição e rolagem dos itens. LazyColumn gera uma lista de rolagem vertical e LazyRow gera uma lista de rolagem horizontal.

Os componentes lentos são diferentes da maioria dos layouts no Compose. Em vez de aceitar um parâmetro de bloco de conteúdo @Composable, permitindo que os apps emitam elementos combináveis diretamente, os componentes lentos fornecem um bloco LazyListScope.(). Esse bloco LazyListScope fornece uma DSL que permite que os apps descrevam o conteúdo do item. O componente lento é responsável por adicionar o conteúdo de cada item conforme exigido pelo layout e pela posição de rolagem.

DSL LazyListScope

A DSL de LazyListScope fornece uma série de funções para descrever itens no layout. No nível mais básico, item() adiciona um único item e items(Int) adiciona vários itens:

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")
    }
}

Há também várias funções de extensão que permitem adicionar conjuntos de itens, como uma List. Essas extensões permitem migrar facilmente o exemplo de Column acima:

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

Há também uma variante da função de extensão items() com o nome itemsIndexed(), que fornece o índice. Consulte a referência LazyListScope para mais detalhes.

Grades lentas

Os elementos combináveis LazyVerticalGrid e LazyHorizontalGrid oferecem suporte à exibição de itens em uma grade. Uma grade vertical lenta mostra os itens em um contêiner de rolagem vertical, dividido em várias colunas, enquanto grades horizontais lentas fazem o mesmo no eixo horizontal.

As grades têm os mesmos recursos avançados da API que as listas e usam uma DSL muito semelhante (LazyGridScope.()) para descrever o conteúdo.

Captura de tela de um smartphone mostrando uma grade de fotos

O parâmetro columns em LazyVerticalGrid e o parâmetro rows em LazyHorizontalGrid controlam como as células são formadas em colunas ou linhas. O exemplo abaixo mostra itens em uma grade, usando GridCells.Adaptive para definir cada coluna com pelo menos 128.dp de largura:

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

LazyVerticalGrid permite especificar uma largura para os itens. A grade vai incluir o máximo de colunas possível. Qualquer largura restante é distribuída igualmente entre as colunas depois que a quantidade é calculada. Essa maneira adaptável de dimensionamento é útil principalmente para exibir conjuntos de itens em diferentes tamanhos de tela.

Se você souber o número exato de colunas a serem usadas, poderá fornecer uma instância de GridCells.Fixed contendo o número de colunas necessárias.

Se o design exigir que apenas alguns itens tenham dimensões não padrão, use o suporte de grade para fornecer períodos de colunas personalizados para os itens. Especifique o período da coluna com o parâmetro span dos métodos LazyGridScope DSL item e items. O maxLineSpan, um dos valores do escopo do período, é útil principalmente quando você está usando o dimensionamento adaptável, já que o número de colunas não é fixo. Este exemplo mostra como mostrar um período completo de linha:

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

Grade escalonada lenta

LazyVerticalStaggeredGrid e LazyHorizontalStaggeredGrid são elementos combináveis que permitem criar uma grade de itens com carregamento lento e escalonado. Uma grade escalonada vertical lenta exibe os itens em um contêiner rolável verticalmente que se estende por várias colunas e permite que itens individuais tenham alturas diferentes. As grades horizontais lentas têm o mesmo comportamento no eixo horizontal com itens de larguras diferentes.

O snippet a seguir é um exemplo básico de como usar LazyVerticalStaggeredGrid com uma largura de 200.dp por item:

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()
)

Figura 1. Exemplo de grade vertical com espaçamento irregular

Para definir um número fixo de colunas, use StaggeredGridCells.Fixed(columns) em vez de StaggeredGridCells.Adaptive. Isso divide a largura disponível pelo número de colunas (ou linhas para uma grade horizontal) e faz com que cada item ocupe essa largura (ou altura para uma grade horizontal):

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()
)
Grade escalonada lenta de imagens no Compose
Figura 2. Exemplo de grade vertical escalonada lenta com colunas fixas.

Padding de conteúdo

Às vezes, você precisará adicionar padding ao redor das bordas do conteúdo. Os componentes lentos permitem transmitir alguns PaddingValues ao parâmetro contentPadding para que ofereçam suporte para o código a seguir:

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

Nesse exemplo, adicionamos 16.dp de padding às bordas horizontais (esquerda e direita) e 8.dp à parte de cima e de baixo do conteúdo.

O padding é aplicado ao conteúdo, não à LazyColumn em si. No exemplo acima, o primeiro item vai adicionar 8.dp de padding à parte de cima, o último item vai adicionar 8.dp à parte de baixo e todos os itens terão 16.dp de padding à esquerda e à direita.

Espaçamento de conteúdo

Para adicionar espaçamento entre os itens, use Arrangement.spacedBy(). O exemplo abaixo adiciona 4.dp de espaço entre cada item:

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

O mesmo ocorreu para LazyRow

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

As grades aceitam arranjos verticais e horizontais:

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

Chaves de itens

Por padrão, o estado de cada item é vinculado à posição dele na lista ou grade. No entanto, isso pode causar problemas se o conjunto de dados mudar, já que os itens que efetivamente mudam de posição perdem qualquer estado memorizado. Imaginando o cenário de uma LazyRow em uma LazyColumn, caso a posição do item na linha mude, o usuário perderá a posição de rolagem dentro da linha.

Para impedir isso, atribua uma chave estável e única a cada item, fornecendo um bloco para o parâmetro key. A chave estável permite que o estado do item seja consistente em todas as mudanças do conjunto de dados:

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

Ao fornecer chaves, você ajuda o Compose a processar as reordenações corretamente. Por exemplo, se o item tiver um estado memorizado, a definição das chaves vai permitir que o Compose mova esse estado junto ao item na mudança de posição.

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

Há uma limitação quanto aos tipos que você pode usar como chaves de itens. O tipo da chave precisa ter suporte do Bundle, o mecanismo do Android para manter os estados quando a atividade é recriada. O Bundle oferece suporte a tipos primitivos, enumerados ou parceláveis.

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

A chave precisa do suporte do Bundle para que o rememberSaveable dentro do elemento combinável do item possa ser restaurado quando a atividade for recriada ou até mesmo quando você rolar a tela para fora desse item e voltar.

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

Animações de itens

Se você já usou o widget RecyclerView, sabe que ele anima as mudanças nos itens de forma automática. Os layouts lentos oferecem a mesma funcionalidade para reordenações de itens. A API é simples. Você só precisa definir o modificador animateItem para o conteúdo do item:

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()) {
            // ...
        }
    }
}

Você pode até fornecer especificações de animação personalizadas, se precisar:

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)
            )
        ) {
            // ...
        }
    }
}

Forneça chaves para seus itens para que seja possível encontrar a nova posição do elemento movido.

Cabeçalhos fixos (experimental)

O padrão "cabeçalho fixo" é útil para exibir listas de dados agrupados. Veja abaixo um exemplo de uma "lista de contatos", agrupada pela inicial de cada contato:

Vídeo de um smartphone rolando para cima e para baixo em uma lista de contatos

Para ter um cabeçalho fixo com LazyColumn, use a função experimental stickyHeader(), informando o conteúdo do cabeçalho:

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

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

Para ter uma lista com vários cabeçalhos, como no exemplo de "lista de contatos" acima, faça o seguinte:

// 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)
            }
        }
    }
}

Como reagir de acordo com a posição de rolagem

Muitos apps precisam reagir e detectar as mudanças de posição e layout dos itens. Os componentes lentos empregam esse caso de uso elevando a LazyListState:

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

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

Para casos de uso simples, os apps geralmente só precisam ter informações sobre o primeiro item visível. Para isso, LazyListState fornece as propriedades firstVisibleItemIndex e firstVisibleItemScrollOffset.

Considerando o exemplo de exibir ou ocultar um botão, dependendo de o usuário rolar a tela até passar do primeiro item:

@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()
        }
    }
}

Ler o estado diretamente na composição é útil quando você precisa atualizar outros elementos combináveis na interface, mas também há cenários em que o evento não precisa ser processado na mesma composição. Um exemplo comum disso é o envio de um evento de análise depois que o usuário rola a tela e passa de um ponto determinado. Para processar isso de forma eficiente, podemos usar um snapshotFlow():

val listState = rememberLazyListState()

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

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

LazyListState também fornece informações sobre todos os itens sendo exibidos no momento e seus limites na tela, usando a propriedade layoutInfo. Consulte a classe LazyListLayoutInfo para ver mais informações.

Como controlar a posição de rolagem

Além de reagir à posição de rolagem, também é útil que os apps possam controlar a posição de rolagem. LazyListState oferece suporte para a função scrollToItem(), que ajusta imediatamente a posição de rolagem, e com animateScrollToItem(), que executa a rolagem usando uma animação (também conhecida como rolagem suave):

@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)
            }
        }
    )
}

Conjuntos de dados grandes (paginação)

A biblioteca Paging permite que os apps ofereçam suporte a grandes listas de itens, carregando e exibindo pequenos pedaços da lista conforme necessário. A Paging 3.0 e versões mais recentes oferecem suporte ao Compose pela biblioteca androidx.paging:paging-compose.

Para exibir uma lista de conteúdo paginado, use a função de extensão collectAsLazyPagingItems() e, em seguida, transmita o resultado retornado LazyPagingItems para items() na LazyColumn. De forma semelhante ao suporte à Paging em visualizações, é possível exibir marcadores de posição enquanto os dados são carregados verificando se item é 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()
            }
        }
    }
}

Dicas para usar layouts lentos

Temos algumas dicas para garantir o funcionamento adequado dos layouts lentos.

Evitar o uso de itens com 0 pixel

Isso pode acontecer em cenários em que, por exemplo, você espera acessar alguns dados de forma assíncrona, como imagens, para preencher os itens da lista em uma fase seguinte. Com essa configuração, o layout lento vai fazer a composição de todos os itens na primeira medida, já que a altura deles vai ser de 0 pixel, cabendo, portanto, na janela de visualização. Depois que os itens forem carregados e a altura ampliada, os layouts lentos vão descartar todos os outros itens combinados sem necessidade na primeira vez, já que eles não cabem na janela de visualização. Para evitar esse problema, defina o dimensionamento padrão para os itens de modo que o layout lento possa fazer o cálculo correto de quantos cabem na janela de visualização:

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

Quando você sabe o tamanho aproximado dos itens depois que os dados são carregados de maneira assíncrona, recomendamos garantir que o dimensionamento permaneça o mesmo antes e depois do carregamento, por exemplo, adicionando alguns marcadores de posição. Isso ajuda a manter a posição de rolagem correta.

Evitar o aninhamento de componentes roláveis na mesma direção

Isso se aplica apenas a casos de aninhamento de elementos filhos roláveis sem um tamanho predefinido dentro de um pai rolável na mesma direção. Por exemplo, veja a tentativa de aninhar uma LazyColumn filha sem uma altura fixa dentro de uma Column mãe rolável verticalmente:

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

O mesmo resultado pode ser alcançado ao envolver todos os elementos combináveis em uma LazyColumn mãe e usar a DSL para transmitir diferentes tipos de conteúdo. Isso permite a emissão de itens únicos e também de vários itens de lista em um só lugar:

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

Lembre-se de que os casos em que você está aninhando layouts de direções diferentes, por exemplo, uma Row mãe rolável e uma LazyColumn filha, são permitidos:

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

Também são permitidos casos em que você ainda usa os mesmos layouts de direção, mas também define um tamanho fixo para os filhos aninhados:

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

Cuidado ao colocar vários elementos em um item

Neste exemplo, a lambda do segundo item emite dois itens em um bloco:

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

Os layouts lentos vão processar tudo como esperado. Os elementos vão ser mostrados um após o outro, como se fossem itens diferentes. No entanto, há alguns problemas com esse comportamento.

Quando vários elementos são emitidos como parte de um item, eles são processados como uma entidade, ou seja, não podem mais ser combinados individualmente. Se um elemento se tornar visível na tela, todos os elementos correspondentes ao item vão precisar ser combinados e medidos. Isso pode prejudicar a performance se usado excessivamente. No caso extremo de colocar todos os elementos em um item, os layouts lentos se tornam totalmente inutilizados. Além de possíveis problemas de performance, colocar mais elementos em um item também vai interferir com scrollToItem() e animateScrollToItem().

No entanto, há casos de uso válidos para colocar vários elementos em um item, como para ter divisores dentro de uma lista. Não é recomendado que os divisores mudem índices de rolagem, já que não podem ser considerados elementos independentes. Além disso, a performance não é afetada, já que os divisores são pequenos. Um divisor provavelmente vai precisar estar visível quando o item anterior também estiver visível para que possa fazer parte desse item:

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

Usar acordos personalizados

Geralmente, as listas lentas têm muitos itens e ocupam mais do que o tamanho do contêiner de rolagem. Quando a lista é preenchida com poucos itens, o design pode ter requisitos mais específicos para definir como eles são posicionados na janela de visualização.

Para isso, use o Arrangement vertical personalizado e o transmita à LazyColumn. No exemplo abaixo, o objeto TopWithFooter só precisa implementar o método arrange. Primeiro, ele vai posicionar os itens um após o outro. Depois, se a altura total usada for menor que a altura da janela de visualização, o rodapé vai ser posicionado na parte de baixo:

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()
        }
    }
}

Adicionar contentType

Para maximizar o desempenho do layout lento, a partir do Compose 1.2 você pode adicionar contentType às listas ou grades. Dessa forma, é possível especificar o tipo de conteúdo de cada item do layout quando você estiver criando uma lista ou grade composta por vários tipos de itens:

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

Quando o contentType é informado, o Compose consegue reutilizar as composições somente entre itens do mesmo tipo. Como a reutilização é mais eficiente ao fazer a composição de itens de estrutura semelhante, informar os tipos de conteúdo garante que o Compose não tente compor um item do tipo A sobre um item do tipo B completamente diferente. Isso ajuda a maximizar os benefícios da reutilização de composições e o desempenho do layout lento.

Medir o desempenho

Só é possível medir de forma confiável a performance de um layout lento quando ele é executado no modo de lançamento com a otimização do R8 ativada. Em builds de depuração, a rolagem do layout lento pode parecer mais devagar. Para mais informações, consulte Performance do Compose.