列表和网格
列表基本介绍
横向纵向非滚动列表可以使用Column、Row(verticalScroll()
修饰符使 Column
可滚动)。如果需要显示大量的列表(或者长度未知列表)需要使用Compose 提供的LazyColumn、LazyRow。这些组件只会对在组件视口中可见的列表项进行组合和布局。
scss
private val lazyColumnColors = listOf(
Color(0xFFffd7d7.toInt()),
Color(0xFFffe9d6.toInt()),
Color(0xFFfffbd0.toInt()),
Color(0xFFe3ffd9.toInt()),
Color(0xFFd0fff8.toInt())
)
@Composable
fun LazyColumnSample() {
LazyColumn(contentPadding = PaddingValues(0.dp)) {
items(100) {
Box(
Modifier
.fillMaxWidth()
.height(50.dp)
.background(lazyColumnColors[it % lazyColumnColors.size])
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun LazyColumnSamplePreview() {
AndroidXComposeTheme {
LazyColumnSample()
}
}
延迟组件与 Compose 中的大多数布局不同。延迟组件不是通过接受 @Composable
内容块参数,来允许应用直接发出可组合项,而是提供了一个 LazyListScope.()
块。此 LazyListScope
块提供一个 DSL,允许应用描述列表项内容。然后,延迟组件负责按照布局和滚动位置的要求添加每个列表项的内容。
LazyListScope (域特定语言)
LazyListScope
的 DSL 提供了多种函数来描述布局中的列表项。最基本的函数包括,item()
用于添加单个列表项,items(Int)
用于添加多个列表项:
scss
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
示例:
scss
/**
* import androidx.compose.foundation.lazy.items
*/
LazyColumn {
items(messages) { message ->
MessageRow(message)
}
}
还有一个名为 itemsIndexed()
的 items()
扩展函数的变体,用于提供索引。详细参考自适应列数网格。
延迟网格
LazyVerticalGrid
和 LazyHorizontalGrid
可组合项为在网格中显示列表项提供支持。延迟垂直网格会在可垂直滚动容器中跨多个列显示其列表项,而延迟水平网格则会在水平轴上有相同的行为。
scss
@Composable
private fun PhotoGridSample() {
val photos = List(100) {
val url = rememberRandomSampleImageUrl(width = 256)
Photo(it, url, url.replace("256", "1024"))
}
LazyVerticalGrid(
// columns = GridCells.Fixed(3) 固定列数
columns = GridCells.Adaptive(minSize = 128.dp) // GridCells.Adaptive 将每列设置为至少 128.dp 宽
) {
items(photos) { photo ->
PhotoItem(photo)
}
}
// [END android_compose_layouts_lazy_grid_adaptive]
}
@Composable
private fun PhotoItem(photo: Photo) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(photo.url)
.build(),
contentDescription = "",
placeholder = painterResource(id = R.mipmap.compose_base_icon),
error = painterResource(id = R.mipmap.compose_base_icon),
modifier = Modifier.size(128.dp)
)
}
通过 LazyVerticalGrid
,您可以指定列表项的宽度,然后网格将适应尽可能多的列。计算列数后,系统会将剩余的宽度平均分配给各列。这种自适应尺寸调整方式非常适合在不同尺寸的屏幕上显示多组列表项。
如果您知道要使用的确切列数,则可以改为提供一个包含所需列数的 GridCells.Fixed
实例。
如果您的设计只需要某些列表项具有非标准尺寸,您可以使用网格支持功能为列表项提供自定义列 span。使用 LazyGridScope DSL
item
和 items
方法的 span
参数指定列 span。maxLineSpan
是 span 范围的值之一,在您使用自适应尺寸调整功能时特别有用,因为此情况下列数不固定。以下示例展示了如何提供完整的行 span:
kotlin
/**
* 网格布局示例
* 本示例演示如何使用LazyVerticalGrid创建一个自适应列数的网格布局
* 每第三个项目跨两列,其他项目跨一列
* 本示例中,类别列表中的每个类别都是一个网格项
*/
@Composable
private fun GridItemSpanExample() {
// 定义一个类别列表,用于在网格中显示
val categories = listOf("Fruits", "Vegetables", "Dairy", "Meat", "Fish", "Snacks", "Beverages", "Bakery")
// 使用LazyVerticalGrid创建一个自适应列数的网格布局
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 50.dp) // 每列最小宽度为50.dp
) {
// 遍历categories列表,为每个类别创建一个网格项
itemsIndexed(categories) { index, category ->
// 根据索引计算spanSize,每第三个项目跨两列,其他跨一列
val spanSize = if ((index + 1) % 3 == 0) GridItemSpan(2) else GridItemSpan(1)
// 使用计算出的spanSize为当前项设置跨列数
this@LazyVerticalGrid.item(span = { spanSize }) {
// 调用CategoryCard函数显示类别名称
CategoryCard(category)
}
}
}
}
@Composable
private fun CategoryCard(category: String) {
Card(
modifier = Modifier.padding(4.dp).background(color = Color.Red),
elevation = 2.dp
) {
Text(
text = category,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.h6
)
}
}
延时交错网格
LazyVerticalStaggeredGrid
和 LazyHorizontalStaggeredGrid
是可组合项,可用于创建延迟加载的项交错网格。延迟垂直交错网格会在可垂直滚动的容器中显示其项,该容器跨越多个列并允许各个项具有不同的高度。对于宽度不同的项,延迟水平网格在横轴上具有相同的行为。
ini
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()
)
间距
内容内边距
围绕内容边缘添加内边距。借助延迟组件,您可以将一些 PaddingValues
传递给 contentPadding
参数以支持此功能:
scss
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
// ...
}
第一个项将 8.dp
内边距添加到顶部,最后一个项将 8.dp
添加到底部,且所有项的左侧和右侧都使用 16.dp
内边距。
内容间距
如需在列表项之间添加间距,可以使用 Arrangement.spacedBy()
。以下示例在每个列表项之间添加了 4.dp
的间距:
scss
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// ...
}
对 LazyRow
也可进行类似的操作。
scss
LazyRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// ...
}
网格既接受垂直排列,也接受水平排列:
scss
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(photos) { item ->
PhotoItem(item)
}
}
项键
在 Compose 中,LazyColumn 用于显示大量数据的列表。默认情况下,LazyColumn 会使用列表中的位置作为每个项目的键。然而,如果列表是可变的(例如,项目可能会被添加或删除),这可能会导致不必要的重新组合,因为 Compose 可能无法正确地识别哪些项目仍然存在,哪些项目已经改变。 为了解决这个问题,可以使用 key 参数为每个项目提供一个唯一的标识符。这样,即使列表的顺序发生变化,Compose 也能正确地识别每个项目,从而避免不必要的重新组合。
key 参数被设置为 task -> task.id,这意味着每个 WellnessTask 的 id 属性被用作其在 LazyColumn 中的唯一键。这样,即使 WellnessTask 的顺序发生变化,Compose 也能正确地识别每个项目,从而提高性能。
rust
LazyColumn {
items(
items = messages,
key = { message ->
// Return a stable + unique key for the item
message.id
}
) { message ->
MessageRow(message)
}
}
通过提供键,您可以帮助 Compose 正确处理重新排序。例如,如果您的项包含记忆状态,设置键将允许 Compose 在项的位置发生变化时将此状态随该项一起移动。
ini
LazyColumn {
items(books, key = { it.id }) {
val rememberedValue = remember {
Random.nextInt()
}
}
}
对于可用作项键的类型有一条限制。键的类型必须受 Bundle
支持,这是 Android 的机制,旨在当重新创建 activity 时保持相应状态。Bundle
支持基元、枚举或 Parcelable 等类型。(primitives, enums, Parcelable, etc)
Bundle
必须支持该键,以便在重新创建 activity 时,甚至在您滚动离开此项然后滚动回来时,此项可组合项中的 rememberSaveable
仍可以恢复。
ini
LazyColumn {
items(books, key = { it.id }) {
val rememberedValue = rememberSaveable {
Random.nextInt()
}
}
}
项动画
用过 RecyclerView widget,便会知道它会自动为列表项更改添加动画效果。延迟布局提供了相同的功能,以用于重新排列列表项。此 API 很简单 - 您只需将 animateItemPlacement
修饰符设置为列表项内容即可:
scss
LazyColumn {
items(books, key = { it.id }) {
Row(Modifier.animateItemPlacement()) {
// ...
}
}
}
在以下情况下,您甚至可以提供自定义动画规格:
scss
LazyColumn {
items(books, key = { it.id }) {
Row(
Modifier.animateItemPlacement(
tween(durationMillis = 250)
)
) {
// ...
}
}
}
需要确保为您的项提供键,以便找到被移动的元素的新位置。
粘性标题
粘性标题模式
less
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun StickHeader() {
// 联系人列表list
val grouped = listOf(
"A" to listOf("Alice", "Ava"),
"B" to listOf("Bob", "Bill", "Brian"),
"C" to listOf("Cindy", "Catherine", "Chris"),
"D" to listOf("David", "Daisy"),
"E" to listOf("Eva", "Ella", "Eric"),
"F" to listOf("Frank", "Fiona"),
"G" to listOf("George", "Grace"),
)
LazyColumn {
grouped.forEach { (initial, contactsForInitial) ->
stickyHeader {
CharacterHeader(initial)
}
items(contactsForInitial) { contact ->
ContactListItem(contact)
}
}
}
}
响应滚动位置
许多应用需要对滚动位置和列表项布局更改作出响应,并进行监听。延迟组件通过提升 LazyListState
来支持。
位置监听
对于简单的用例,应用通常只需要了解第一个可见列表项的相关信息。为此,LazyListState
提供了 firstVisibleItemIndex
和 firstVisibleItemScrollOffset
属性。
如果我们使用根据用户是否滚动经过第一个列表项来显示和隐藏按钮的示例。
scss
@Composable
fun LazyColumnSampleV2() {
Box {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(100) {
Box(
Modifier
.fillMaxWidth()
.height(50.dp)
.background(lazyColumnColors[it % lazyColumnColors.size])
)
}
}
// 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()
}
}
}
@Composable
private fun ScrollToTopButton() {
Box(
Modifier
.fillMaxWidth()
.height(50.dp)
.background(C_P1),
contentAlignment = Alignment.Center
) {
Text(
text = "Scroll to top",
color = Color.White
)
}
}
注意:上面的示例使用了 derivedStateOf()
,以便最大限度地减少不必要的组合。如需了解详情,请参阅附带效应文档。
snapshotFlow
当您需要更新其他界面可组合项时,在组合中直接读取状态非常有效,但在某些情况下,系统无需在同一组合中处理此事件。一个常见的例子是,系统会在用户滚动经过某个点后发送分析事件。为了高效地解决此问题,我们可以使用 snapshotFlow()
:
scss
@Composable
private fun LazyListStateSample() {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(exposedIndices.size) { index ->
Box(
Modifier
.fillMaxWidth()
.height(50.dp)
.background(lazyColumnColors[index % lazyColumnColors.size])
){
Text(
text = "Item $index",
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { it > 0 }
.distinctUntilChanged() // Only emit when the value changes
.filter { it }
.collect {
Log.d("LazyListStateSample", "First item is past the first visible item")
}
}
}
超级曝光方法
对于项目中可能存在埋点曝光的应用场景,使用 snapshotFlow()
能够很好的根据快照状态来展示我们想要得到的目标数据
kotlin
/**
*
* 用于处理曝光事件的组件
* 该组件会监听列表滚动事件,当列表中的某个item曝光时,会调用onExpose回调
* @param listState LazyListState,列表的滚动状态
* @param data List<T>,列表数据
* @param onExpose (Pair<Int, T>) -> Unit,曝光事件回调,参数为曝光的item的索引和数据
* @param T 列表数据的类型
* @param exposeOnce Boolean,是否只曝光一次,默认为false
*/
@Composable
fun <T> HandleExposureEvents(
listState: LazyListState,
data: List<T>,
onExpose: (Pair<Int, T>) -> Unit,
exposeOnce: Boolean = false
) {
var exposedIndices by remember { mutableStateOf(setOf<Int>()) }
var exposedOnceIndices by remember { mutableStateOf(setOf<Int>()) }
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo }
.collect { visibleItems ->
val visibleIndices = visibleItems.map { it.index }.toSet()
// Report exposure for newly visible items
val newExposedIndices = visibleIndices - exposedIndices
newExposedIndices.forEach { index ->
if (!exposeOnce || !exposedOnceIndices.contains(index)) {
onExpose(Pair(index, data[index]))
if (exposeOnce) {
exposedOnceIndices += index
}
}
}
// Remove the indices of items that are no longer visible
exposedIndices = exposedIndices - (exposedIndices - visibleIndices) + newExposedIndices
}
}
}
ini
// 创建一个列表,初始化 [0,100]
private val exposedIndices = (0..99).toMutableList()
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyColumnSample() {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(exposedIndices.size) { index -> }
}
HandleExposureEvents(
listState = listState,
data = exposedIndices,
onExpose = { (index, data) ->
Log.d("LazyColumnSample", "Item $index exposed, data: $data")
},
exposeOnce = true
)
}
控制滚动位置
除了对滚动位置作出响应外,如果应用能够控制滚动位置,也会非常有帮助。 LazyListState
通过以下函数支持此操作:scrollToItem()
函数,用于"立即"捕捉滚动位置;animateScrollToItem()
使用动画进行滚动(也称为平滑滚动):
注意:scrollToItem()
和 animateScrollToItem()
都是挂起函数,这意味着我们需要在协程中调用这些函数。如需详细了解如何在 Compose 中执行此操作,请参阅我们的协程文档。
ini
@Composable
private fun ScrollToTopSample() {
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
state = listState,
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(exposedIndices.size) { index ->
Box(
Modifier
.fillMaxWidth()
.height(50.dp)
.background(lazyColumnColors[index % lazyColumnColors.size])
) {
Text(
text = "Item $index",
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
ScrollToTopButton(
onClick = {
coroutineScope.launch {
// Animate scroll to the first item
listState.animateScrollToItem(index = 0)
}
}
)
}
@Composable
private fun ScrollToTopButton(onClick:()->Unit = {}) {
Box(
Modifier
.fillMaxWidth()
.height(50.dp)
.background(C_P1)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Text(
text = "Scroll to top",
color = Color.White
)
}
}
大型数据集(分页)
借助 Paging 库,应用可以支持包含大量列表项的列表,根据需要加载和显示小块的列表。Paging 3.0 及更高版本通过 androidx.paging:paging-compose
库提供 Compose 支持。
注意:只有 Paging 3.0 及更高版本提供 Compose 支持。如果您使用的是较低版本的 Paging 库,则需先迁移到 3.0。
如需显示分页内容列表,可以使用 collectAsLazyPagingItems()
扩展函数,然后将返回的 LazyPagingItems
传入 LazyColumn
中的 items()
。与视图中的 Paging 支持类似,您可以通过检查 item
是否为 null
,在加载数据时显示占位符:
kotlin
@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()
}
}
}
}
警告:如果您使用 RemoteMediator
从网络服务中提取数据,请务必提供实际大小的数据占位符项。如果您使用 RemoteMediator
,系统会重复调用该函数以提取新数据,直到内容填满屏幕为止。如果提供了小占位符(或者根本没有占位符),那么屏幕可能永远不会被填满,而且您的应用会提取许多页的数据。