《Jetpack Compose系列学习》-14 Compose中的竖向列表LazyColumn

1,358 阅读2分钟

在Android View中列表通常使用ListView或者RecyclerView,我们需要在xml里定义每个条目Item的布局,再创建一个适配器adapter,RecyclerView还需要设置它的LayoutManager等。

在Compose中,竖向、横向和网格列表都有自己的专门控件,使用起来更加方便简单,其中竖向列表就是用LazyColumn控件去实现,我们先看看它的简单使用:

val dataList = arrayListOf<Int>() // 数据源
for (index in 0 .. 10) { 
    dataList.add(index)
}
LazyColumn {
    items(dataList) { data ->
        Text("item:$data") // Item布局
    }
}

我们创建了一个数据源list,通过其构造方法设置数据源,并指定Item布局样式为简单文本Text。

image.png

LazyListScope

我们看看LazyColumn的源码:

@Composable
fun LazyColumn(
    modifier: Modifier = Modifier, // 修饰符
    state: LazyListState = rememberLazyListState(), // 控制或观察列表状态的对象
    contentPadding: PaddingValues = PaddingValues(0.dp), // 内容内边距
    reverseLayout: Boolean = false, // 是否反向布局
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, // 垂直配列方式
    horizontalAlignment: Alignment.Horizontal = Alignment.Start, // 水平排列方式
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), // 描述fling的逻辑
    content: LazyListScope.() -> Unit // 描述内容的代码块
) {
    // 省略...
}

有很多参数之前了解过,我们重点看看LazyListScope,它的类型是LazyListScope块,LazyColumn与Compose中大多数布局不同,它不接收@Composable内容块参数,从而允许应用程序直接发出可组合项,而是提供一个LazyListScope块。LazyListScope块提供了DSL,允许应用程序描述列表项内容。然后,LazyColumn负责按照布局和滚动位置的要求添加每个列表项的内容。我们先看看LazyListScope里面到底写了什么代码能让LazyColumn这么好用:

@LazyScopeMarker
interface LazyListScope {
    /**
     * 添加一个项目
     */
    fun item(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)

    /**
     *  添加count个项目
     */
    fun items(
        count: Int,
        key: ((index: Int) -> Any)? = null,
        itemContent: @Composable LazyItemScope.(index: Int) -> Unit
    )

    /**
     *  添加一个粘性标题,标题头将保持固定状态,直到下一个标题头取代它
     */
    @ExperimentalFoundationApi
    fun stickyHeader(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)
}

通过LazyListScope的源码可知它是一个接口,里面有两个添加项目的方法,还有一个添加粘性标题的方法,可以看到item方法和items方法中的最后一个参数是我们熟悉的@Composable内容块参数,所以我们可以在LazyColumn中写item布局。

问题来了,item方法和items方法都不可以直接传入List或者Array,但是我们平时在项目中构建列表时使用的都是List或Array,怎么办?上面的例子我们也是通过items方法直接传入List,而上面使用的items方法并不在LazyListScope接口中,那它在哪里定义的呢?没错,就是扩展方法,LazyListScope有好几个扩展方法,我们看看:

// 添加项目列表
inline fun <T> LazyListScope.items(
    items: List<T>,
    noinline key: ((item: T) -> Any)? = null,
    crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) = items(items.size, if (key != null) { index: Int -> key(items[index]) } else null) {
    itemContent(items[it])
}

// 添加项目列表,通过项目的内容可以知道其item对应的索引
inline fun <T> LazyListScope.itemsIndexed(
    items: List<T>,
    noinline key: ((index: Int, item: T) -> Any)? = null,
    crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
) = items(items.size, if (key != null) { index: Int -> key(index, items[index]) } else null) {
    itemContent(it, items[it])
}

// 添加项目数组
inline fun <T> LazyListScope.items(
    items: Array<T>,
    noinline key: ((item: T) -> Any)? = null,
    crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) = items(items.size, if (key != null) { index: Int -> key(items[index]) } else null) {
    itemContent(items[it])
}

// 添加一个数组,通过项目的内容可以知道其item的索引
inline fun <T> LazyListScope.itemsIndexed(
    items: Array<T>,
    noinline key: ((index: Int, item: T) -> Any)? = null,
    crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
) = items(items.size, if (key != null) { index: Int -> key(index, items[index]) } else null) {
    itemContent(it, items[it])
}

提供的四个扩展方法,分别可以使用List和Array添加条目,如果需要获取item对应的索引,可以使用itemsIndexed扩展方法。我们一起看个例子:

val dataList = arrayListOf<String>()
for (index in 0 .. 10) {
    dataList.add("ind".repeat(index))
}
LazyColumn {
    itemsIndexed(dataList) { index, data ->
        Text("item:第${index}个数据为$data")
    }
}

根据对应的index值进行“ind”字符串的重复显示,效果如下:

image.png

使用Array的itemsIndexed

当我们用Array当做数据源时,我们就用参数为Array的LazyListScope的扩展方法,如下:

val dataList = arrayListOf<String>()
for (index in 0 .. 10) {
    dataList.add("ind".repeat(index))
}
LazyColumn {
    itemsIndexed(dataList.toArray()) { index, data ->
        Text("item:第${index}个数据为$data")
    }
}

效果同上图。

多Type

我们知道recyclerView展示多type类型的item时,我们需要创建多个样式的item布局,在适配器adapter里重写对应的方法,根据type类型创建不同的item模板来展示,比较麻烦,但是LazyColumn实现多type就比较简单,我们看看具体分为哪几步。 首先我们创建一个数据类:

data class Chat(
    val content: String, // 内容
    val isLeft: Boolean = true) // 是否靠左显示

再创建数据源:

val chatList = arrayListOf<Chat>()
chatList.apply { 
    add(Chat("你好"))
    add(Chat("在家干啥呢?"))
    add(Chat("出来玩啊?"))
    add(Chat("没啥事", false))
    add(Chat("在家呆着呢", false))
}

再用LazyColumn实现多Type展示:

LazyColumn {
    items(chatList) { data ->
        if (data.isLeft) {
            Column(modifier = Modifier.padding(end = 15.dp)) {
                Spacer(modifier = Modifier.height(5.dp))
                Text(
                    data.content, modifier = Modifier.fillMaxWidth().height(25.dp)
                        .background(Color.Yellow)
                )
            }
        } else {
            Column(modifier = Modifier.padding(start = 15.dp)) {
                Spacer(modifier = Modifier.height(5.dp))
                Text(
                    data.content, modifier = Modifier.fillMaxWidth()
                        .background(Color.Green).height(25.dp)
                )
            }
        }
    }
}

image.png

对,就这么简单。但在真正项目中开发对应的业务时,最好将item的布局抽出来,进行状态提升,更好的提高性能。

粘性标题

粘性标题很常见,如微信中的通讯录,即使滚动通讯录列表,上方的字母索引标题固定不动,直到下一个标题取代它:

image.png

那么在Compose中怎么实现这种粘性标题效果呢?前面说的LazyListScope接口中有一个stickyHeader方法,就是做这个的。一个列表中可能只有一个标题或者一块布局需要跟随列表向上滚动,当滑动到顶部的时候这块布局需要吸附在页面顶部位置不动,而下面的列表可以继续向上滚动。这个效果在Android View中实现比较麻烦,在Compose中比较简单,来看看代码实现:

LazyColumn {
    items(chatList) { item ->
        Text(
            item.content,
            modifier = Modifier.padding(10.dp).background(Color.Red).height(150.dp)
                .fillMaxWidth(), textAlign = TextAlign.Center,
            fontSize = 35.sp
        )
    }

    stickyHeader {
        Text(
            "粘性标题啊",
            modifier = Modifier.padding(10.dp).background(Color.Green).height(150.dp)
                .fillMaxWidth(), textAlign = TextAlign.Center,
            fontSize = 35.sp
        )
    }

    items(chatList) { item ->
        Text(
            item.content,
            modifier = Modifier.padding(10.dp).background(Color.Red).height(150.dp)
                .fillMaxWidth(), textAlign = TextAlign.Center,
            fontSize = 35.sp
        )
    }

}

212.gif

我们可以看到,LazyColumn中在一个粘性头部上下分别插入了列表数据,能更直观的看到粘性标题的效果。

复杂的粘性标题

如果我们要固定多个粘性标题怎么做呢?像上面说的微信通讯录一样,我们看看要哪几步去实现这个通讯录效果,首先也是创建一个数据类:

data class Contact(
    val letters: String, // 字母索引
    val nameList: List<String>) // 下方名字列表

数据源

val letters = arrayListOf("A", "B", "C", "D", "E")
val contactList = arrayListOf<Contact>()
val nameList = arrayListOf<String>()
for (index in 0..5) {
    nameList.add("路人$index")
}
for (index in letters.iterator()) {
    contactList.add(Contact(letters = index, nameList))
}

创建列表视图

LazyColumn {
    contactList.forEach { (letter, nameList) ->
        stickyHeader {
            Text(
                letter,
                modifier = Modifier.padding(10.dp).background(Color.Green)
                    .fillMaxWidth(), textAlign = TextAlign.Center,
                fontSize = 35.sp
            )
        }

        items(nameList) { contact ->
            Text(
                contact,
                modifier = Modifier.padding(10.dp).background(Color.Red).height(50.dp)
                    .fillMaxWidth(), textAlign = TextAlign.Center,
                fontSize = 35.sp
            )
        }
    }
}

效果如下:

333.gif

列表回到顶部

现在很多场景,点击手机状态栏会让列表滚动到最初始的位置,Android View中的RecyclerView调用smoothScrollToPosition(0)即可。在Compose中,因为是数据驱动UI显示,通过监听State的改变从而改变页面,这里也是。我们知道LazyColumn的方法体中有一个参数LazyListState,这就是LazyColumn的State,我们通过改变它的值value从而触发页面的刷新。 首先:我们创建LazyListState

val listState = rememberLazyListState()

只需要调用rememberLazyListState方法就可以创建一个LazyListState,然后可以将它传入到LazyColumn中:

LazyColumn(state = listState) {
    // 省略...
}

调用LazyListState的scrollToItem或animateScrollToItem方法就可以滚动到指定的位置:

val coroutineScope = rememberCoroutineScope()
Button(
    modifier = Modifier.width(200.dp).height(40.dp),
    onClick = {
    coroutineScope.launch {
        listState.animateScrollToItem(index = 0)
    }
}) {
    Text("点击回到顶部")
}

这里需要注意,animateScrollToItem是挂起函数(suspend修饰的):

suspend fun animateScrollToItem(
    /*@IntRange(from = 0)*/
    index: Int,
    /*@IntRange(from = 0)*/
    scrollOffset: Int = 0
) {
    doSmoothScrollToItem(index, scrollOffset)
}

我们需要在协程中使用,所以我们通过rememberCoroutineScope方法创建一个协程作用域,在里面调用回调顶部的方法,实现效果:

666.gif

完整代码:

val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()

val letters = arrayListOf("A", "B", "C", "D", "E")
val contactList = arrayListOf<Contact>()
val nameList = arrayListOf<String>()
for (index in 0..5) {
    nameList.add("路人$index")
}
for (index in letters.iterator()) {
    contactList.add(Contact(letters = index, nameList))
}

Box(modifier = Modifier.fillMaxSize()) {
    LazyColumn(state = listState) {
        contactList.forEach { (letter, nameList) ->
            stickyHeader {
                Text(
                    letter,
                    modifier = Modifier
                        .padding(10.dp)
                        .background(Color.Green)
                        .fillMaxWidth(), textAlign = TextAlign.Center,
                    fontSize = 35.sp
                )
            }

            items(nameList) { contact ->
                Text(
                    contact,
                    modifier = Modifier
                        .padding(10.dp)
                        .background(Color.Red)
                        .height(50.dp)
                        .fillMaxWidth(), textAlign = TextAlign.Center,
                    fontSize = 35.sp
                )
            }
        }
    }

    Button(
        modifier = Modifier.width(200.dp).height(40.dp),
        onClick = {
            coroutineScope.launch {
                listState.animateScrollToItem(index = 0)
            }
        }) {
        Text("点击回到顶部")
    }
}

喜欢的点个赞收藏下吧。代码已上传的github:github.com/Licarey/com…