列表和网格

许多应用需要显示项目集合。本文档介绍如何在 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)
    }
}

还有一种 items() 扩展函数的变体称为 itemsIndexed(),它提供索引。有关更多详细信息,请参阅 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 是允许您创建惰性加载的交错项目网格的可组合项。惰性垂直交错网格在其跨越多列的垂直可滚动容器中显示其项目,并允许各个项目具有不同的高度。惰性水平网格在水平轴上具有相同行为,项目具有不同的宽度。

以下代码段是使用每项宽度为 200.dpLazyVerticalStaggeredGrid 的基本示例。

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 支持诸如基本类型、枚举或 Parcelable 等类型。

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

键必须受Bundle 支持,以便当 Activity 重新创建时,甚至当您从该项目滚动离开然后又滚动回来时,项目可组合项内的rememberSaveable 都可以恢复。

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

项目动画

如果您使用过 RecyclerView 组件,您就会知道它会自动为项目更改设置动画。延迟布局为项目重新排序提供了相同的功能。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)
            )
        ) {
            // ...
        }
    }
}

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

粘性标题(实验性)

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

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

响应滚动位置

许多应用需要响应并侦听滚动位置和项目布局更改。延迟组件通过提升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()
        }
    }
}

在组合中直接读取 state 在您需要更新其他 UI 可组合项时很有用,但也有一些场景不需要在同一组合中处理事件。一个常见的示例是在用户滚动到某个点之后发送分析事件。为了高效地处理这个问题,我们可以使用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 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 传递不同类型的 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 布局的目的。除了潜在的性能问题外,在一个项目中放置更多元素还会干扰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 不会尝试将 A 类型项目组合在完全不同的 B 类型项目之上。这有助于最大限度地提高组合重用的好处和 Lazy 布局的性能。

测量性能

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