Jetpack Compose(第四趴)——Compose中的延迟布局(上)

2,100 阅读10分钟

了解如何在Compose中制作滚动列表,以及这样为什么比使用RecyclerView更简单。了解为什么不允许嵌套滚动列表、如何采用不同方式实现嵌套、为什么列表项的大小决不能为0像素、为什么提供唯一的键非常重要,以及项动画如何运作。最后,您将探索如何显示网络、使用自定义布局管理器,以及了解如何改进性能优化以提高滚动速度。

一、延迟布局和Recycler的区别

如果你以前用过RecyclerView,那应该很熟悉延迟布局背后的主要概念:也就是在屏幕显示可滚动的项列表时,分别渲染各个项,而非一次性渲染所有项。

image.png

如果你需要加载大量项或大型数据集,通过使用延迟布局,你可以按需添加内容,从而提升应用的性能和响应能力。

目前,在Compose 1.2中延迟布局包括

  • LazyColumn,它是垂直滚动列表
  • LazyRow,它是水平滚动列表
  • LazyGrids,它提供这两种列表。

image.png

但View系统中使用的RecyclerView和Compose中使用延迟列表之间有一个很大的不同:区别就在于需要编写的代码量上。

RecyclerView

class FlowersAdapter(private val onClick: (Flower) -> Unit): ListAdapter<Flower, FlowersAdapter.FlowerViewHolder>(FlowerDiffCallback) {
    
    class FlowerViewHolder(itemView: View, val onClick: (Flower) -> Unit): RecyclerView.ViewHolder(itemView) {
        private val flowerTextView: TextView = itemView.findViewById(R.id.flower_text)
        ...
        fun bind(flower: Flower) {
            ...
        }
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FlowerViewHolder {
        ...
    }
    
    override fun onBindViewHolder(holder: FlowerViewHolder, position: Int) {
        ...
    }
}

这是你需要为RecyclerView适配器和ViewHolder编写的代码

这是RecyclerView的XML布局代码:

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recycler_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutManager="LinearLayoutManager" />

这是RecyclerView项布局的代码:

<LinearLayout
    ...>
    
    <ImageView
        android:id="@+id/flower_image"
        android:layout_width="48dp"
        android:layout_height="48dp"
        ... />
        
    <TextView
        android:id="@+id/flower_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        ... />
        
</LinearLayout>

最后是将适配器绑定到RecyclerView所需的代码:

class FlowerListActivity: AppCompatActivity() {
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(saveInstanceState)
        setContentView(R.layout.activity_main)
        
        val flowersAdapter = FlowerAdapter { flower -> adapterOnClick(flower) }
        
        val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
        recyclerView.adapter = flowersAdapter
        ...
    }
}

这是在使用延迟列表时同样的工作量需要的代码:

@Composable
fun FlowerList(flowers: List<Flower>) {
    LazyColumn {
        item(flowers) { flower ->
            FlowerItem(flower)
        }
    }
}
@Composable
fun FlowerItem(flower: Flower) {
    Column {
        Image(flower.image)
        Text(flower.name)
    }
}

它们实现的结果相同,但所需的代码更少,明显更易于读取和写入。

如果要在延迟列表中添加项,可以使用LazyListScope DSL块来接受提供的内容,并将其显示为列表项

LazyColumn {
    // LazyListScope block
    item {
        Text(header)
    }
    items(data) { item -> 
        Item(item)
    }
}

image.png

这种方法与Compose中的其他常规布局略有不同,你可以只管描述内容,延迟列表会处理其余所有工作。

这个API提供了两种插入方式,你可以用包含一个项的块描述一个项

image.png 或用包含多个项的块描述多个项

image.png

也可以在同一列表中使用两者的组合来描述内容。

如果你需要知道每个项的索引,例如,为了给偶数项和奇数项设置不同的颜色,你可以使用itemsIndexed块来获取相应信息。

LazyColumn {
    // LazyListScope block
    item {
        Text(header)
    }
    itemsIndexed(data) { index, item -> 
        Item(item, index)
    }
}

image.png

二、自定义列表外观

2.1、contentPadding

添加项后,下一步是考虑如何自定义列表的外观

image.png

使用延迟列表,这会非常简单。

例如,围绕列表内容添加一些内边距是一个常见的用例,如果你只想缩进整个列表,使用内边距修饰符可以轻松做到

image.png

LazyRow(
    modifier = Modifier.padding(
        start = 24.dp,
        end = 24.dp
    )
)  {
    items(data) {item ->
        Item(item)
    }
}

但是,这样在浮动第一项和最后一项时,为了使内容保持在列表内边距的边界内就会裁剪内容。

image.png

如果你希望保留相同的内边距,同时仍然在列表的边界内滚动内容而且还不裁剪,可以向列表的contentPadding参数传递PaddingValues,这样你就可以分别为每一侧设置相同的内边距

LazyRow(
    contentPadding = PaddingValues(
        start = 24.dp,
        end = 24.dp
    )
) {
    items(data) { item ->
        Item(item)
    }
}

image.png

2.2、Arrangement.spaceBy

现在我们来进一步优化内容的界面,默认情况下,你的列表会像这样黏在一起。

image.png

如果要在列表项之间添加一些整齐的间距,你可以使用Arrangement.spaceBy将它传递到列表

LazyRow(
    horizontalArrangement = Arrangement.spaceBy(8.dp)
) {
    items(data) { item ->
        Item(item)
    }
}

image.png

2.3、LazyListState

由于延迟列表旨在用于有大量内容或项要显示的情况,因此你有可能需要执行大量滚动操作来浏览列表。所以需要关注的关键功能之一是如何以编程方式观察和相应滚动操作。

这里的关键是LazyListState它是一个重要的状态对象,可存储滚动位置并包含关于你的列表的实用信息

LazyColumn(
    state = rememberLazyListState()
) {
    items(data) { item ->
        Item(item)
    }
}

为了确保各个组合都会被记住你的状态,请使用rememberLazyListState提升它并将它传递到列表

val state = rememberLazyListState()

LazeColumn(
    state = state
) {
    items(data) { item ->
        Item(item)
    }
}

有了LazyListState,你可以获取第一个可见项的索引和偏移量。这是相应滚动时最常用的两个属性

val state = rememberLazyListState()

state.firseVisiableItemIndex
state.firstVisibleItemScrollOffset

例如,你可以根据第一个可见项来确定是否显示用于滚动到列表顶部的按钮

image.png

val state = rememberLazyListState()

val showScrollToTopButton by remember {
    derivedStateOf {
        state.firstVisibleItemIndex > 0
    }
}

请注意,LazyListState属性经常变化,仅在一个组合内读取其属性会触发大量可能并无必要的重组。为避免这种情况,可以将它封装在rememberderivedStateOf中。这样可以确保只有当计算中使用的状态属性发生变化时才会进行重组。

LazyListState还可以通过layoutInfo对象提供其他有用信息,例如当前可见的项和总项数

state.layoutInfo.visibleItemsInfo
state.layoutInfo.totalItemCount

state.layoutInfo.visibleItemsInfo
        .map { it.index }

举例来说,如果你希望在项完全可见时播放其中的内容,在不完全可见时暂停,可以利用这个对象来提取所有当前显示的项的索引,现在,我们来看一个实例完全实现“滚动至顶部”按钮。

image.png

我们可以使用LazyListState因为它提供一个方便的挂起函数scrollToItem(),让我们可以快速返回或前往特定位置的某个项

val state = rememberLazyListState()

ScrollToTopButton(
    onClick = {
        // suspend function
        state.scrollToItem(
            index = 0
        )
    }
)

如果你喜欢更流畅的动画转场效果,可以使用animateScrollToItem

val state = rememberLazyListState()

ScrollToTopButton(
    onClick = {
        // suspend function
        state.animateScrollToItem(
            index = 0
        )
    }
)

请注意,它们都是挂起函数,所以需要从rememberCoroutineScope提供的协程作用域中调用,然后,在按钮的onClick参数内的协程作用域内启动滚动函数

val state = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()

ScrollToTopButton(
    onClick = {
        coroutineScope.launch {
            state.animateScrollToItem(
                index = 0
            )
        }
    }
)

三、LazyGrid

Compose还提供了开箱即用的延迟网络,延迟网络最近经过重新设计。有了新的功能,API也在Compose 1.2中从试验阶段升级为稳定版。

我们来详细了解一下:你可以通过LazyVerticalGridLazyHorizontalGrid可组合项使用延迟网格。

LazyVerticalGrid会在可垂直滚动的容器中跨多个列显示所含的项,而LazyHorizontalGrid则会在水平轴上有相同的行为。

image.png

网格的用法非常简单,与LazyColumn的用法基本相同。只不过添加了对垂直网格列的描述。在这里,我们制定网格应该固定有两列。

LazyVerticalGrid(
    columns = GridCells.Fiexd(2)
) {
    items(data) { item ->
        Item(item)
    }
}

使用Compose中的网格与使用列表非常相似。列表与网格具有同样强大的API功能并且它们还使用非常相似的DSL来描述内容。

例如,如果要在项之间添加间距,我们只需使用spacedBy排列方式,这是相同之处。不同之处在于网格同时具有垂直和水平排列方式。

LazyVerticalGrid(
    colomn = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spaceBy(16.dp),
    horizontalArrangement = Arrangement.spaceBy(16.dp)
) {
    items(data) { item ->
        Item(item)
    }
}

image.png

网格会接受contentPaddinglazyGridState,后者具有与适用于列表的LazyListState一样实用的功能。LazyGridState会公开例如firstVisibleItemIndexlayoutInfo等信息。这是为了便于了解网格的当前布局状态。对于滚动,它也提供同样的API,即scrollToItemanimateScrollToItem

LazyVerticalGrid(
    contentPadding = PaddingValues(...),
    state = state // LazyGridState
    ...
)

state.firstVisibleItemIndex // Int
state.layoutInfo// LazyGridLayoutInfo
state.scrollToItem(...)
state.animateScrollToItem(...)
LazyColumn(
    contentPadding = PadddingValues(...),
    state = state // LazyListStatte
    ...
)

state.firstVisibleItemIndex // Int
state.layoutInfo // LazyListLayoutInfo
state.scrollToItem(...)
state.animateScrollToItem(...)

我们之前了解了如何实现具有固定列竖的垂直网格,由于列数固定,网格可用宽度除以列数后所得的值,就是每个列占用的空间量,如果还添加了spacedBy排列方式,就会先从可用宽度中减去间距,然后再

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    horizontalArrangement = Arrangements.spaceBy(24.dp)
)

image.png

不过,使用固定列数也并非完美适合所有情况方案,在SunFlower的例子中,我们使用了固定有两列的网格,我们在手机上测试了这个示例应用,效果不错。

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    horizontalArrangement = Arrangement.spaceBy(24.dp),
    verticalArrangement = Arrangement.spaceBy(24.dp)
) {
    items(plants) {plant ->
        PlantCard(plant)
    }
}

image.png

但在平板电脑上运行时,效果并不是很好,图片过宽并且由于职务卡片的高度是固定的,原始图片的很大一部分被裁剪掉了。

image.png

3.1、GridCells.Adaptive

为了解决这个问题,我们通过GridCells.Adaptive API为列使用了自适应尺寸调整功能。这样我们就可以指定项的宽度,然后网格会容纳尽可能多的列。计算列竖后系统会将剩余的宽度平均分配给各列。

LazyVerticalGrid(
    columns = GridCells.Adaptive(128.dp),
    horizontalArrangement = Arrangement.spaceBy(24.dp),
    verticalArrangement = Arrangement.spaceBy(24.dp)
) {
    items(plants) { plant ->
        PlantCard(plant)
    }
}

image.png

这种自适应尺寸调整方式尤其适合在不同尺寸的屏幕上很好地显示多组项。

但是如果我们有更复杂的尺寸调整要求,该怎么办?好消息是,Compose支持完全自定义,你可以实现GridCells。它是通过固定网格单元和自适应网格单元实现的接口。

LazyVerticalGrid(
    columns = object: GridCells {
        ...
    }
)

这个接口只有一个方法用于计算列配置。对于垂直网格,他会为你计算可用宽度以及请求的项之间的水平间距。这个方法的返回值是一个列表其中包含计算出得列宽度

LazyVerticalGrid(
    columns = object: GridCells {
        override fun Density.calculateCrossAxisCellSizes(
            availableSize: Int,
            spacing: Int,
        ): List<Int> {
            ...
        }
    }
)

在我们的例子中,假设第一列的宽度应为第二列的两倍,我们这样计算列宽度,将第一列的宽度调整为可用空间减去间距,再乘以2/3所得的值,第二列占据剩余空间。最后,返回包含宽度计算值的列表。可以看到列数与所返回列表的长度相符

LazyVerticalGrid(
    columns = object: GridCells {
        override fun Density.calculateCrossAxisCellSizes(
            availableSize: Int,
            spacing: Int
        ): List<Int> {
            val firstColumn = (availableSize - spacing) * 2 / 3
            val secondColumn = availableSize - spacing - firstColumn
            return list(firstColumn, secondColumn(
        }
    }
)

image.png

3.2、实际场景

现在我们已经了解如何定义GridCells的结构,如果你的设计需要只让某些额项采用非标准尺寸,该怎么办?来看看这个Sunflower示例应用的设计,它对植物进行了分类。我们希望每个类别的其实位置有一个标题,显示相应类别的名称。每个标题占据行的全部宽度。

ezgif.com-video-to-gif.gif

为实现这种设计,网格支持为项提供自定义列span,这可以通过网格范围DSL中itemitems方法的span参数指定。在这里,我们为标题提供完整的行span。、

LazyVerticalGrid(
    ...
) {
    item(span = {
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
}

image.png

maxLineSpan,它是span范围的值之一。在使用自适应尺寸调整功能时,列数不固定,maxLineSpan就会特别有用

LazyVerticalGrid(
    ...
) {
    item(span = {
        // LazyGridItemSpanScope:
        // maxLineSpan
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
}

image.png

span lambda范围中,还有一个值maxCurrentLineSpan,它表示项在当前行中可以占据的span,当项不在行的开头时,该值与maxLineSpan不同。

LazyVerticalGrid(
    ...
) {
    item(span = {
        // LazyGridItemSpanScope:
        // maxLineSpan
        // maxCurrentLineSpan = 2
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
}

image.png

现在来看一下植物卡片,我们不需要明确为项提供span,因为每个项占据的span都为1,即默认值为1

LazyVerticalGrid(
    ...
) {
    item(span = {
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
    items(fruitPlants) { plant ->
        PlantCard(plant)
    }
}

image.png

不过,假设我们需要突出显示某些项,为此,可以设定自定义span。在这个例子中,每隔一个元素的span将为2.它是平板电脑上的呈现效果。

LazyVerticalGrid(...) {
    item(span = { GridItemSpan(maxLineSpan) } { ... }
    items(
        fruitPlants.size,
        span = { GridItemSpan(if(it.isOdd) 2 else 1) } { plant ->
            PlantCard(fruitPlants[it])
        }
    )
}

image.png

这是它在手机上的呈现效果:

image.png

请注意,当前行中放不下的元素会自动换行显示,留下空白