列表网格

列表和网格

列表基本介绍

横向纵向非滚动列表可以使用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() 扩展函数的变体,用于提供索引。详细参考自适应列数网格。

延迟网格

LazyVerticalGridLazyHorizontalGrid 可组合项为在网格中显示列表项提供支持。延迟垂直网格会在可垂直滚动容器中跨多个列显示其列表项,而延迟水平网格则会在水平轴上有相同的行为。

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 itemitems 方法的 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
        )
    }
}

延时交错网格

LazyVerticalStaggeredGridLazyHorizontalStaggeredGrid 是可组合项,可用于创建延迟加载的项交错网格。延迟垂直交错网格会在可垂直滚动的容器中显示其项,该容器跨越多个列并允许各个项具有不同的高度。对于宽度不同的项,延迟水平网格在横轴上具有相同的行为。

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 提供了 firstVisibleItemIndexfirstVisibleItemScrollOffset 属性。

如果我们使用根据用户是否滚动经过第一个列表项来显示和隐藏按钮的示例。

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,系统会重复调用该函数以提取新数据,直到内容填满屏幕为止。如果提供了小占位符(或者根本没有占位符),那么屏幕可能永远不会被填满,而且您的应用会提取许多页的数据。

相关推荐
氤氲息2 小时前
Android 底部tab,使用recycleview实现
android
Clockwiseee3 小时前
PHP之伪协议
android·开发语言·php
小林爱3 小时前
【Compose multiplatform教程08】【组件】Text组件
android·java·前端·ui·前端框架·kotlin·android studio
小何开发4 小时前
Android Studio 安装教程
android·ide·android studio
开发者阿伟4 小时前
Android Jetpack LiveData源码解析
android·android jetpack
weixin_438150994 小时前
广州大彩串口屏安卓/linux触摸屏四路CVBS输入实现同时显示!
android·单片机
CheungChunChiu5 小时前
Android10 rk3399 以太网接入流程分析
android·framework·以太网·eth·net·netd
木头没有瓜5 小时前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
键盘侠0076 小时前
springboot 上传图片 转存成webp
android·spring boot·okhttp
江上清风山间明月6 小时前
flutter bottomSheet 控件详解
android·flutter·底部导航·bottomsheet