列表和网格

许多应用都需要显示列表项集合。本文档将介绍如何在 Jetpack Compose 中高效地执行此操作。

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

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

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

延迟列表

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

Compose 提供了一组组件,这些组件只会对在组件视口中可见的列表项进行组合和布局。这些组件包括 LazyColumnLazyRow

从名称可以看出,LazyColumnLazyRow 之间的区别就在于它们的列表项布局和滚动方向不同。LazyColumn 生成的是垂直滚动列表,而 LazyRow 生成的是水平滚动列表。

延迟组件与 Compose 中的大多数布局不同。延迟组件不是通过接受 @Composable 内容块参数,来允许应用直接发出可组合项,而是提供了一个 LazyListScope.() 块。此 LazyListScope 块提供一个 DSL,允许应用描述列表项内容。然后,延迟组件负责按照布局和滚动位置的要求添加每个列表项的内容。

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.() 来描述内容。

一部手机的屏幕截图,其中以网格形式显示了一些照片

LazyVerticalGrid 中的 columns 参数和 LazyHorizontalGrid 中的 rows 参数用于控制单元格组成列或行的方式。以下示例在网格中显示列表项,并使用 GridCells.Adaptive 将每列设置为至少 128.dp 宽:

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

通过 LazyVerticalGrid,您可以指定列表项的宽度,然后网格将适应尽可能多的列。计算列数后,系统会将剩余的宽度平均分配给各列。这种自适应尺寸调整方式非常适合在不同尺寸的屏幕上显示多组列表项。

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

如果您的设计只需要某些列表项具有非标准尺寸,您可以使用网格支持功能为列表项提供自定义列 span。使用 LazyGridScope DSL itemitems 方法的 span 参数指定列 span。maxLineSpan 是 span 范围的值之一,在您使用自适应尺寸调整功能时特别有用,因为此情况下列数不固定。以下示例展示了如何提供完整的行 span:

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

项键

默认情况下,每个列表项的状态均与该项在列表或网格中的位置相对应。但是,如果数据集发生变化,这可能会导致问题,因为位置发生变化的列表项实际上会丢失任何记忆状态。想象一下 LazyColumn 中的 LazyRow 场景,如果某个行更改了项位置,用户将丢失在该行内的滚动位置。

为避免出现此情况,您可以为每个列表项提供一个稳定的唯一键,为 key 参数提供一个块。提供稳定的键可使项状态在发生数据集更改后保持一致:

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

通过提供键,您可以帮助 Compose 正确处理重新排序。例如,如果您的项包含记忆状态,设置键将允许 Compose 在项的位置发生变化时将此状态随该项一起移动。

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

不过,对于可用作项键的类型有一条限制。键的类型必须受 Bundle 支持,这是 Android 的机制,旨在当重新创建 activity 时保持相应状态。Bundle 支持基元、枚举或 Parcelable 等类型。

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

Bundle 必须支持该键,以便在重新创建 activity 时,甚至在您滚动离开此项然后滚动回来时,此项可组合项中的 rememberSaveable 仍可以恢复。

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

项动画

如果您使用过 RecyclerView widget,便会知道它会自动为列表项更改添加动画效果。延迟布局提供了相同的功能,以用于重新排列列表项。此 API 很简单 - 您只需将 animateItem 修饰符设置为列表项内容即可:

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

在以下情况下,您甚至可以提供自定义动画规格:

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

您需要确保为您的项提供键,以便找到被移动的元素的新位置。

粘性标题(实验性)

“粘性标题”模式在显示分组数据列表时非常有用。 下面显示的是“联系人列表”示例,其中的数据按照每个联系人的名字首字母分组:

视频:在手机中上下滚动联系人列表

如需使用 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)
            }
        }
    }
}

响应滚动位置

许多应用需要对滚动位置和列表项布局更改作出响应,并进行监听。延迟组件通过提升 LazyListState 来支持此用例:

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

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

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

如果我们使用根据用户是否滚动经过第一个列表项来显示和隐藏按钮的示例,那么代码如下:

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

当您需要更新其他界面可组合项时,在组合中直接读取状态非常有效,但在某些情况下,系统无需在同一组合中处理此事件。一个常见的例子是,系统会在用户滚动经过某个点后发送分析事件。为了高效地解决此问题,我们可以使用 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()
            }
        }
    }
}

关于使用延迟布局的提示

您不妨考虑下列提示,以确保您的延迟布局按预期运行。

避免使用大小为 0 像素的列表项

这可能会在某些情况下发生,例如,当您希望在后期阶段异步检索一些数据(例如图片)以填充列表项时。这会使延迟布局在首次衡量时组合其所有项,因为项的高度为 0 像素,所以这类项可完全适合视口大小。待这些项加载完毕且高度增加后,延迟布局随后会舍弃首次不必要组合起来的所有其他项,因为这些项实际上无法适合视口。为避免出现这种情况,您应该为您的项设置默认大小,这样可使延迟布局正确地计算有多少项实际上适合视口:

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

相反,通过将所有可组合项封装在一个父级 LazyColumn 内,并使用其 DSL 传入不同类型的内容,则可以得到相同的结果。这样,系统就可以在一个位置既发出单个项又发出多个项:

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

延迟布局会按预期处理这种情况 - 逐个布置各元素,就像它们是不同的项一样。不过,这样做会出现一些问题。

当多个元素作为一个项的一部分发出时,系统会将其作为一个实体进行处理,这意味着这些元素无法再单独组合。如果一个元素在屏幕上可见,则与该项对应的所有元素都必须组合和衡量。如果过度使用,则可能会降低性能。将所有元素放入一个项属于极端情况,完全违背了使用延迟布局的目的。除了潜在的性能问题外,将更多元素放入一个项中还会干扰 scrollToItem()animateScrollToItem()

不过,也有一些将多个元素放入一个项的有效用例,例如在一个列表内添加多条分隔线。您不希望分隔线更改滚动索引,因为分割线不应被视为独立元素。此外,由于分隔线占空间很小,因此性能不会受到影响。分隔线可能需要在其之前的那个项可见时显示,这样分割线就可纳入前一个项中:

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

考虑使用自定义排列方式

通常,延迟列表包含许多项,并且这些项所占空间大于滚动容器的大小。不过,如果列表中填充的项很少,那么在设计中,您可以对这些项在视口中的位置做出更具体的要求。

为实现此目的,您可以使用自定义垂直 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 开始,为了最大限度地提高延迟布局的性能,建议将 contentType 添加到您的列表或网格中。当您的列表或网格由多种不同类型的项组成时,这样可让您为布局的每一项指定内容类型:

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

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

衡量性能

只有在发布模式下运行且启用了 R8 优化时,您才能可靠地衡量延迟布局的性能。在调试 build 中,延迟布局滚动可能会显得更慢。如需了解详情,请仔细阅读 Compose 性能