列表和网格

许多应用都需要显示项目的集合。本文档说明如何在 Jetpack Compose 中高效地执行此操作。

如果您知道您的用例不需要任何滚动,则可能希望使用简单的 ColumnRow(取决于方向),并通过以下方式迭代列表来发出每个项目的內容

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

我们可以通过使用 verticalScroll() 修饰符使 Column 可滚动。

懒加载列表

如果您需要显示大量项目(或长度未知的列表),则使用 Column 等布局可能会导致性能问题,因为所有项目都将被组合和布局,无论它们是否可见。

Compose 提供了一组组件,这些组件仅组合和布局组件视口中可见的项目。这些组件包括 LazyColumnLazyRow

顾名思义,LazyColumnLazyRow 之间的区别在于它们布局项目和滚动的方向。LazyColumn 生成一个垂直滚动的列表,而 LazyRow 生成一个水平滚动的列表。

Lazy 组件与 Compose 中的大多数布局不同。Lazy 组件没有接受一个 @Composable 内容块参数,允许应用直接发出可组合项,而是提供了一个 LazyListScope.() 块。此 LazyListScope 块提供了一个 DSL,允许应用描述项目内容。然后,Lazy 组件负责根据布局和滚动位置添加每个项目的內容。

LazyListScope DSL

LazyListScope 的 DSL 提供了许多用于描述布局中项目的函数。最基本的是,item() 添加单个项目,而 items(Int) 添加多个项目

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

还有一些扩展函数允许您添加项目的集合,例如 List。这些扩展允许我们轻松地迁移我们上面提到的 Column 示例

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

还有一个名为 itemsIndexed()items() 扩展函数的变体,它提供了索引。有关更多详细信息,请参阅 LazyListScope 参考。

懒加载网格

LazyVerticalGridLazyHorizontalGrid 可组合项提供支持以网格形式显示项目。懒加载垂直网格将在一个垂直可滚动的容器中显示其项目,跨越多列,而懒加载水平网格将在水平轴上具有相同的行为。

网格具有与列表相同的强大 API 功能,并且它们还使用非常类似的 DSL - LazyGridScope.() 来描述内容。

Screenshot of a phone showing a grid of photos

LazyVerticalGrid 中的 columns 参数和 LazyHorizontalGrid 中的 rows 参数控制单元格如何形成列或行。以下示例使用 GridCells.Adaptive 将每列的宽度设置为至少 128.dp 来显示网格中的项目

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

LazyVerticalGrid 允许您为项目指定宽度,然后网格将尽可能多地容纳列。在计算列数后,剩余的宽度将平均分配到各列。这种自适应的大小调整方式对于在不同屏幕尺寸上显示项目集特别有用。

如果您知道要使用的列数,则可以改为提供一个包含所需列数的 GridCells.Fixed 实例。

如果您的设计要求只有某些项目具有非标准尺寸,则可以使用网格支持为项目提供自定义列跨度。使用 LazyGridScope DSLitemitems 方法的 span 参数指定列跨度。maxLineSpan(跨度范围的值之一)在使用自适应大小调整时特别有用,因为列数不是固定的。此示例显示如何提供整行跨度

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

懒加载交错网格

LazyVerticalStaggeredGridLazyHorizontalStaggeredGrid 是可组合项,允许您创建懒加载的交错网格项目。懒加载垂直交错网格在其项目中显示一个垂直可滚动的容器,该容器跨越多列,并允许单个项目具有不同的高度。懒加载水平网格在水平轴上具有相同的行为,项目具有不同的宽度。

以下代码段是使用 LazyVerticalStaggeredGrid 和每个项目宽度为 200.dp 的基本示例

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

图 1. 懒加载交错垂直网格示例

要设置固定数量的列,可以使用 StaggeredGridCells.Fixed(columns) 代替 StaggeredGridCells.Adaptive。这将可用宽度除以列数(或水平网格的行数),并使每个项目占用该宽度(或水平网格的高度)

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()
)
Lazy staggered grid of images in Compose
图 2. 固定列的懒加载交错垂直网格示例

内容填充

有时您需要在内容边缘添加填充。懒加载组件允许您将一些 PaddingValues 传递给 contentPadding 参数以支持此功能

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

在此示例中,我们在水平边缘(左和右)添加 16.dp 的填充,然后在内容的顶部和底部添加 8.dp 的填充。

请注意,此填充应用于内容,而不是应用于 LazyColumn 本身。在上面的示例中,第一个项目将在其顶部添加 8.dp 的填充,最后一个项目将在其底部添加 8.dp 的填充,并且所有项目都将在左侧和右侧具有 16.dp 的填充。

内容间距

要在项目之间添加间距,可以使用Arrangement.spacedBy()。下面的示例在每个项目之间添加了4.dp的间距。

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

类似地,对于LazyRow

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

但是,网格同时接受垂直和水平排列。

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

项目键

默认情况下,每个项目的 state 都是根据其在列表或网格中的位置作为键。但是,如果数据集发生变化,这可能会导致问题,因为更改位置的项目实际上会丢失任何记住的 state。如果您想象一下LazyRowLazyColumn中的场景,如果该行更改了项目位置,则用户将丢失该行中的滚动位置。

为了解决这个问题,您可以为每个项目提供一个稳定且唯一的键,向key参数提供一个块。提供一个稳定的键可以使项目 state 在数据集更改时保持一致。

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

通过提供键,您可以帮助 Compose 正确处理重新排序。例如,如果您的项目包含记住的 state,则设置键将允许 Compose 在项目位置更改时将此 state 与项目一起移动。

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

但是,您可以用作项目键的类型存在一个限制。键的类型必须受Bundle(Android 在 Activity 重新创建时保持 state 的机制)支持。Bundle支持诸如基本类型、枚举或 Parcelables 之类的类型。

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

键必须受Bundle支持,以便在 Activity 重新创建时,甚至在您从该项目滚动离开并滚动回来时,都可以恢复项目 composable 中的rememberSaveable

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

项目动画

如果您使用过 RecyclerView 组件,您就会知道它会自动为项目更改设置动画。Lazy 布局为项目重新排序提供了相同的功能。API 很简单 - 您只需要将animateItemPlacement修饰符设置为项目内容。

LazyColumn {
    items(books, key = { it.id }) {
        Row(Modifier.animateItemPlacement()) {
            // ...
        }
    }
}

如果需要,您甚至可以提供自定义动画规范。

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItemPlacement(
                tween(durationMillis = 250)
            )
        ) {
            // ...
        }
    }
}

确保您为项目提供了键,以便能够找到已移动元素的新位置。

除了重新排序之外,添加和删除项目的动画目前正在开发中。您可以在issue 150812265中跟踪进度。

粘性标题(实验性)

“粘性标题”模式在显示分组数据的列表时很有用。下面您可以看到一个“联系人列表”的示例,该列表按每个联系人的首字母分组。

Video of a phone scrolling up and down through a contacts list

要使用LazyColumn实现粘性标题,您可以使用实验性的stickyHeader()函数,提供标题内容。

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

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

要实现一个具有多个标题的列表,例如上面的“联系人列表”示例,您可以执行以下操作。

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

对滚动位置做出反应

许多应用程序需要对滚动位置和项目布局更改做出反应并侦听它们。Lazy 组件通过提升LazyListState来支持此用例。

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

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

对于简单的用例,应用程序通常只需要了解有关第一个可见项目的信息。为此,LazyListState提供了firstVisibleItemIndexfirstVisibleItemScrollOffset属性。

如果我们使用显示和隐藏按钮的示例,具体取决于用户是否已滚动到第一个项目之后。

@OptIn(ExperimentalAnimationApi::class)
@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()
        }
    }
}

在组合中直接读取 state 在您需要更新其他 UI composable 时很有用,但也有不需要在同一组合中处理事件的场景。一个常见的示例是在用户滚动到某个点之后发送分析事件。为了有效地处理这种情况,我们可以使用snapshotFlow()

val listState = rememberLazyListState()

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

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

LazyListState还通过layoutInfo属性提供有关当前显示的所有项目及其屏幕边界的信息。有关更多信息,请参阅LazyListLayoutInfo类。

控制滚动位置

除了对滚动位置做出反应之外,应用程序能够控制滚动位置也很有用。LazyListState通过scrollToItem()函数(“立即”捕捉滚动位置)和animateScrollToItem()函数(使用动画滚动,也称为平滑滚动)支持此功能。

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

大型数据集(分页)

Paging 库使应用程序能够支持大型项目列表,并根据需要加载和显示列表的小块。Paging 3.0 及更高版本通过androidx.paging:paging-compose库提供 Compose 支持。

要显示分页内容的列表,我们可以使用collectAsLazyPagingItems()扩展函数,然后将返回的LazyPagingItems传递到我们LazyColumn中的items()。与视图中的 Paging 支持类似,您可以在数据加载时显示占位符,方法是检查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()
            }
        }
    }
}

使用 Lazy 布局的提示

您可以考虑一些提示,以确保您的 Lazy 布局按预期工作。

避免使用 0 像素大小的项目

这可能发生在例如您期望异步检索某些数据(如图像)以在稍后阶段填充列表项目的情况下。这会导致 Lazy 布局在其第一次测量中组合所有项目,因为它们的高度为 0 像素,并且可以将它们全部放入视口中。一旦项目加载并其高度扩展,Lazy 布局将丢弃所有其他不必要地第一次组合的项目,因为它们实际上无法容纳视口。为了避免这种情况,您应该为项目设置默认大小,以便 Lazy 布局可以正确计算实际上可以容纳多少个项目在视口中。

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

当您知道异步加载数据后项目的近似大小时,一个好的做法是确保项目的大小在加载前后保持一致,例如,通过添加一些占位符。这将有助于保持正确的滚动位置。

避免嵌套在同一方向上可滚动的组件

这仅适用于在另一个相同方向的可滚动父级内部嵌套没有预定义大小的可滚动子级的情况。例如,尝试在垂直可滚动的Column父级内部嵌套一个没有固定高度的子LazyColumn

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

相反,可以通过将所有 composable 包裹在一个父LazyColumn中并使用其 DSL 传递不同类型的 content 来实现相同的结果。这使得能够在一个地方发出单个项目以及多个列表项目。

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

请记住,您嵌套不同方向布局的情况(例如,可滚动的父Row和子LazyColumn)是允许的。

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

以及您仍然使用相同方向的布局,但还为嵌套的子级设置了固定大小的情况。

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

小心在一个项目中放置多个元素

在此示例中,第二个项目 lambda 在一个块中发出了 2 个项目。

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

Lazy 布局将按预期处理此问题 - 它们将一个接一个地布置元素,就好像它们是不同的项目一样。但是,这样做存在几个问题。

当多个元素作为一项的一部分发出时,它们被视为一个实体,这意味着它们不能再单独组合。如果一个元素在屏幕上变得可见,则必须组合和测量与该项目对应的所有元素。如果过度使用,这可能会损害性能。在将所有元素都放在一个项目中的极端情况下,它完全违背了使用 Lazy 布局的目的。除了潜在的性能问题之外,在一个项目中放置更多元素还会干扰scrollToItem()animateScrollToItem()

但是,在单个项目中放置多个元素也有一些合理的用例,例如在列表中添加分隔符。您不希望分隔符更改滚动索引,因为它们不应该被视为独立的元素。此外,由于分隔符很小,性能也不会受到影响。分隔符可能需要在其前面的项目可见时可见,因此它们可以成为前一个项目的一部分。

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

考虑使用自定义排列

通常,Lazy 列表包含许多项目,并且它们占据的空间超过滚动容器的大小。但是,当您的列表填充的项目很少时,您的设计可能对这些项目在视口中如何定位有更具体的需要。

为了实现这一点,您可以使用自定义的垂直 Arrangement 并将其传递给 LazyColumn。在下面的示例中,TopWithFooter 对象只需要实现 arrange 方法。首先,它会将项目一个接一个地定位。其次,如果总使用高度低于视口高度,它会将页脚定位到底部。

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

考虑添加 contentType

从 Compose 1.2 开始,为了最大限度地提高 Lazy 布局的性能,请考虑向您的列表或网格添加 contentType。这使您能够为布局的每个项目指定内容类型,在您组合由多种不同类型的项目组成的列表或网格的情况下。

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

当您提供 contentType 时,Compose 能够仅在相同类型的项目之间重用组合。由于当您组合结构相似的项目时,重用效率更高,因此提供内容类型可确保 Compose 不会尝试在完全不同的类型 B 项目之上组合类型 A 的项目。这有助于最大限度地发挥组合重用和 Lazy 布局性能的优势。

衡量性能

只有在发布模式下并且启用了 R8 优化时,才能可靠地衡量 Lazy 布局的性能。在调试版本中,Lazy 布局滚动可能看起来较慢。有关此内容的更多信息,请阅读 Compose 性能