Android Compose为列表添加Header和Footer

1,431 阅读9分钟

一、前言

最近一段时间一直在忙于其他事情,博客写的很少了。当然,传统UI布局和Compose UI布局在过去的文章中也写了很多了。传统布局方面,从普通的View绘制到布局定义,从布局定义再到LayoutManager定义,我们几乎都有所涉及,可以说囊括了方方面面。Compose UI方面,我们从基础的用法到绘制,以及一些简单的布局也有涉及,就Compose UI而言,其组件丰富度上还是不错的,官方实现了很多布局效果。

作为合格的Android开发者,理解Android 协调机制是非常必要的。

本篇效果

fire_170.gif

1.1 本篇意义

这种布局的意义在于,其本身是一种【基础构型】,就相当于给你一个开发框架,你就可以DIY出自己想要的效果。

具体可以改造出哪些效果呢?这里我们简单列举一下

  • 上拉加载
  • 下拉刷新
  • Header + Tab吸顶效果
  • Footer + Tab吸底效果
  • 协调者布局行为效果

当然,还能解决轮播效果中,类似ViewPager被回收后如何恢复等问题。

1.2 要点

1.2.1 名词解释

  • Header:布局中最顶部的Item
  • Footer:布局中最底部的Item
  • LazyList:可以自身滑动的列表,如LazyColumn等,本篇统称LazyList

1.2.2 为什么使用ScrollConnection

之所以写本篇文章,主要是在之前的文章中,我们有一篇文章是《Android 为RecyclerView添加可吸顶Header》,这篇文章中通过Android Scrolling机制的协调实现,为RecyclerView实现了一个可以吸顶的HeaderView。

在本篇,我们也会使用到Scrolling机制,当然,在Compose UI中,Scrolling机制具体实现就是ScrollConnection,可想而知,无论是传统布局还是Compose UI布局,Scrolling机制是非常简单易用,使用起来非常高效的,不然Compose UI就不会移植这一套机制了。

二、原理

本篇比之前的文章将更进一步,这里我们不仅仅添加HeaderView,还会添加Footer。可能你会问,具体原理是什么呢?

2.1 结构特征

首先,我们看下本篇的结构

2.1.1 列表露出时效果

企业微信20240720-090143@2x.png

可以看出,LazyList和父布局大小相等

2.1.2 滑动时效果

企业微信20240720-090617@2x.png

可以看到,LazyList和Header、Footer还会协调滑动,下面我们做一下总结。

特点如下:

  • LazyList和父布局的高度相等
  • Header和Footer大小可以随意
  • 父布局中UI能滑动的最大范围为Header + Footer的总高度
  • LazyList自身滑动、Item复用不受影响
  • 当LazyList不能滑动时,Header和Footer会联动,直到最大和最小偏移位置
  • Header和Footer不会被回收

三、代码实现

本篇我们当然需要用到ScrollConnection,当然与其他文章不同的是,本篇我们会使用到Compose自定义布局,使用的组件是Layout,在之前的文章《Compose自定义旋转菜单》中我们就使用Layout自定义了一个环形旋转菜单,有兴趣的开发者可以看看。

3.1 ScrollConnection 机制

Compose中Modifier的nestedScroll修饰符定义嵌套滚动层次结构来提高灵活性。这部分和传统的View布局相似,但是相比而言要简单的多一些。

这套机制是一套应答机制,从滑动前到滑动后的整个过程,协调父布局、爷孙布局、子布局产生更加连贯的滑动动作。

object : NestedScrollConnection {
    //用于提前拦截,当然,LazyList也会按照自身优先级选择不调用此方法
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return super.onPreScroll(available, source)  
    }
  // 用于消费后事件处理,比如LazyList已经到了不能滑动的位置了,就会回调此方法
    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return super.onPostScroll(consumed, available, source)
    }
   //和onPreScroll 类似,这里我们暂时不处理,因为要计算瞬时速度,还是比较麻烦
    override suspend fun onPreFling(available: Velocity): Velocity {
        return super.onPreFling(available)
    }
    //和onPostScroll 类似,这里我们暂时不处理,因为要计算瞬时速度,还是比较麻烦
    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return super.onPostFling(consumed, available)
    }
}

onPreScroll:预处理滑动事件,先交给父组件消费后再交由子组件 available:当前可用滑动偏移量 source:滑动类型 返回值:当前消费的滑动偏移量,如果不想消费可返回Offset.Zero。

onPostScroll:子组件滑动后的回调 consumed:之前件消费滑动偏移量 available:当前剩余可用滑动偏移量 source:滑动事件的类型 返回值:当前消费的滑动偏移量,如果不想消费可返回Offset.Zero,则剩下偏移量会继续交由父组件进行处理

onPreFling 惯性滚动事件预处理。 available:开始时的速度 返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero

onPostFling 惯性滚动事件处理 consumed:之前消费的所有速度 available:当前剩余可用的速度 返回值:当前组件消费的速度,如果不想消费可返回Velocity.Zero,则剩下速度会继续交由父组件进行处理。

3.2 LazyList状态监控

实际上在Compose自定义布局中,无法拿到具体的Compose组件,就很难获取LazyList相关状态,因此,在初始化时,我们不得不做一些耦合度方面的妥协。

通过下面的方式,我们将LazyListState传递给NestedScrollConnection子类

val state: LazyListState = rememberLazyListState()
nestedScrollModifierNode.initLazyState(state)
LazyColumn (
    state = state,
    verticalArrangement = Arrangement.spacedBy(1.dp)
){
}

3.3 记录Compose Node基本信息

这里我们简单标记下header、footer、content的最大高度、当前偏移位置

data class ComposeNestedOffset(var key:String, var max: Float, var value: Float)

3.3 滚动逻辑

在代码中,我们要实现滚动逻辑其实很简单,只需要计算相应的偏移量即可,这里我们使用了协程,为什么要使用协程呢?因为在实际的滑动过程,不使用协程会导致卡顿。

private fun scroll(target: ComposeNestedOffset, offset : ComposeNestedOffset, canConsumed: Float): Offset {
    return if (canConsumed.absoluteValue > 0.0f) {
        target.value += canConsumed
        //在这里更新而不是在协程中,避免同步事件触发多次
        coroutineScope.launch {
            contentOffset.value = lazyListState?.firstVisibleItemScrollOffset?.toFloat() ?: 0f;
            graphicYOffset.value = target.value + offset.value  //更新偏移距离

            lazyListState?.apply {
                if((this.firstVisibleItemIndex + this.layoutInfo.visibleItemsInfo.size) == this.layoutInfo.totalItemsCount){
                    loadMore(); //利用公式触发加载更多
                }
            }
        }
        Offset(0f, canConsumed)
    } else {
        Offset.Zero
    }
}

拿到偏移量graphicYOffset之后,我们滚动父布局即可。

这里要说的是,和传统布局一样,Compose UI也分两种滚动,一种基于父布局Matrix变换,另一种是LazyList的中子Item的滑动。前者在Item少的情况下性能更高,但是Item多的话性能就会变差,这也是为什么有ScrollView之后还需要RecyclerView最根本的原因。

当然,我们这里是使用基于Matrix的变换,因为父布局只有Header、Footer、LazyList三个子Item

graphicsLayer {
    translationY = connection.graphicYOffset.value
    Log.d(TAG, "translationY = ${connection.graphicYOffset}")
}

3.3 测量和布局

我们使用Compose Layout组件一个好处就是可以从整体上控制Item自身的大小和位置。

关键部分请看注释。


val placeables = measurables.mapIndexed { index, measurable ->

    if (contentIndex == index) {
       //LazyList保证和父布局大小一致
        val boxWidth = constraints.maxWidth
        val boxHeight = constraints.maxHeight
        val matchParentSizeConstraints = Constraints(
            minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
            minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
            maxWidth = boxWidth,
            maxHeight = boxHeight
        )
        connection.contentOffset.max = boxHeight.toFloat()
        measurable.measure(matchParentSizeConstraints)
    } else {
        val measure = measurable.measure(constraints)
        if(index < contentIndex){
           //header 大小记录
            connection.headerOffset.max = measure.height.toFloat()
        }else if(index > contentIndex){
           // footer 大小记录
            connection.footerOffset.max =  measure.height.toFloat()
        }
        measure
    }
}

// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
    var yPosition = 0
    placeables.forEach() {  placeable ->
        //从上到下布局Header、Footer、LazyList
        placeable.placeRelative(x = 0, y = yPosition)
        yPosition += placeable.height
    }

}

3.4 Header和Footer事件处理

Header和Footer默认是无法滑动的,因此我们需要监听时间,触发滑动方法,以此来实现Header、Footer、LazyList的协调滑动。

pointerInput("header-footer-capture"){
    //由于事件存在优先级,lazyList的优先级更高,我们只需要处理header和footer即可
    detectDragGestures(onDragStart = {
        if(findDragTarget(connection,it) == connection.headerOffset){
            Log.d(TAG,"onDragStart Header $it")
            connection.dispatchUserDragger(connection.headerOffset)
        }else if(findDragTarget(connection,it) == connection.footerOffset){
            Log.d(TAG,"onDragStart Footer $it")
            connection.dispatchUserDragger(connection.footerOffset)
        }
    }, onDragEnd = {
        connection.dispatchUserDragger(null)
    }){change, dragAmount ->
        Log.d(TAG,"onDrag $dragAmount")
        connection.dispatchUserScroll(dragAmount);
    }
}

3.5 核心控制逻辑

我们使用ScrollConnection,必然要处理相关回调,在这部分由于代码太长,这里我们不会写很多原理性的东西,而是对一些重要代码加入注释,方便理解。

这一部分相当关键,也是我们后续如果要实现下拉刷新、吸顶逻辑的关键,因此一定要认真阅读。

class SimpleNestedScrollConnection(
    var coroutineScope: CoroutineScope
) : NestedScrollConnection{

    private var dragger: ComposeNestedOffset? = null
    private var lazyListState: LazyListState? = null
    val headerOffset = ComposeNestedOffset("header",0F, 0F)
    val footerOffset = ComposeNestedOffset("footer",0F, 0F)
    var contentOffset  = ComposeNestedOffset("content",0F, 0F)
    var graphicYOffset = mutableFloatStateOf(0F)


    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        Log.d(TAG,"$available")
        return when {
            available.y < 0  && headerOffset.max != 0f  -> {
                if(headerOffset.value > -headerOffset.max) {
                    //拦截向上滑动,不能超过最大范围,这里只处理header ,因为header优先级较高于footer,同时防止被lazylist消费
                    val offset =  if(available.y + headerOffset.value < -headerOffset.max){
                        -headerOffset.max - headerOffset.value
                    }else{
                        available.y
                    }
                    scroll(headerOffset, ComposeNestedOffset("",0F, 0F), offset)
                }else{
                    Offset.Zero
                }
            }

            available.y > 0  -> {
                if(lazyListState?.canScrollForward == false  && footerOffset.max != 0f){
                    //footer向下滚动需要提前拦截,否则可能导致被LazyList消费,这时footer比header优先级高
                    val offset = if(available.y + footerOffset.value > 0){
                        abs(footerOffset.value)
                    }else{
                        available.y
                    }
                    scroll(footerOffset, headerOffset, offset)
                }else{
                    Offset.Zero
                }
            }

            else ->{
                Offset.Zero
            }
        }
    }

    //下面是处理没有被lazylist 消费的事件
    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return when {
            available.y < 0  -> {
                //拦截向上滑动,不能超过最大范围,这里只处理footer ,因为footer这个时候优先级最低
                if(lazyListState?.canScrollForward == false  &&  footerOffset.max != 0f) {
                    val offset =  if(available.y + footerOffset.value < -footerOffset.max){
                        -footerOffset.max - footerOffset.value  //保证不小于边界值
                    }else{
                        available.y
                    }
                    // 这个时候底部漏出来,那么translationY 是两者之和
                    scroll(footerOffset, headerOffset, offset)
                }else{
                    Offset.Zero
                }
            }

            available.y > 0  -> {
                //拦截向上滑动,不能超过最大范围,这里只处理header ,因为header这个时候优先级最低
                if(lazyListState?.canScrollBackward == false &&  headerOffset.max != 0f && headerOffset.value < 0){
                    val offset =  if(available.y + headerOffset.value > 0){
                        abs(headerOffset.value)  //保证不大于边界值
                    }else{
                        available.y
                    }
                    //说明在顶部,这时候footerOffset理论上也是0,这里写成这样为了更加直观
                    scroll(headerOffset, ComposeNestedOffset("",0F, 0F), offset)
                }else{
                    Offset.Zero
                }
            }
            else -> {
                Offset.Zero
            }
        }
    }


    fun dispatchUserScroll(dragAmount: Offset){
        when{
            dragAmount.y < 0 -> {
                if(dragger == headerOffset && headerOffset.max != 0f) {
                    //向上时,header优先拦截
                    onPreScroll(dragAmount,NestedScrollSource.Drag);
                }else if(dragger == footerOffset && footerOffset.max != 0f){
                    //向下时,footer优先拦截
                    onPostScroll(Offset(0f,0f),dragAmount,NestedScrollSource.Drag)
                }
            }
            dragAmount.y > 0 ->{
                if(dragger == headerOffset && headerOffset.max != 0f) {
                    //向下时,header优先拦截
                    onPostScroll(Offset(0f,0f),dragAmount,NestedScrollSource.Drag)
                }else if(dragger == footerOffset && footerOffset.max != 0f){
                    onPreScroll(dragAmount,NestedScrollSource.Drag);
                }
            }
             else -> {
                Offset.Zero
            }
        }

    }

    private fun scroll(target: ComposeNestedOffset, offset : ComposeNestedOffset, canConsumed: Float): Offset {
        return if (canConsumed.absoluteValue > 0.0f) {
            target.value += canConsumed
            //在这里更新而不是在协程中,避免同步事件触发多次
            coroutineScope.launch {
                contentOffset.value = lazyListState?.firstVisibleItemScrollOffset?.toFloat() ?: 0f;
                graphicYOffset.value = target.value + offset.value  //更新偏移距离

                lazyListState?.apply {
                    if((this.firstVisibleItemIndex + this.layoutInfo.visibleItemsInfo.size) == this.layoutInfo.totalItemsCount){
                        loadMore(); //利用公式触发加载更多
                    }
                }
            }
            Offset(0f, canConsumed)
        } else {
            Offset.Zero
        }
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        return super.onPreFling(available)
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return super.onPostFling(consumed, available)
    }

    fun loadMore(){
    }

    fun initLazyState(state: LazyListState) {
        lazyListState = state
    }

    fun dispatchUserDragger(dragger: ComposeNestedOffset?) {
        this.dragger = dragger;
    }

}

以上就是核心逻辑,基本上到这里核心逻辑就完成了。

四、使用

上面我们实现了核心逻辑,在定义完成之后,就能方便使用了,前面说过有一丁点的耦合逻辑无法避免,在下面的代码中也指出来了。

setContent {
    SwipeRefreshColumn(headerIndicator = {
       Box (modifier = Modifier
           .fillMaxWidth()
           .height(100.dp)
           .background(Color.White),
            contentAlignment = Alignment.Center
           ){
           Text(text = "Hi, I am header")
       }
    }, footerIndicator = {
        Box (modifier = Modifier
            .fillMaxWidth()
            .height(100.dp)
            .background(Color.White),
            contentAlignment = Alignment.Center){
            Text(text = "ooh,long time no see")
        }
    }) { nestedScrollModifierNode ->
        val state: LazyListState = rememberLazyListState()
        nestedScrollModifierNode.initLazyState(state)  
        //耦合逻辑,用于监控LazyColumn的状态
        LazyColumn (
            state = state,
            verticalArrangement = Arrangement.spacedBy(1.dp)
        ){
            val list = (0..25).map { it.toString() }
            items(count = list.size) {
               Box (modifier = Modifier
                   .fillMaxWidth()
                   .height(80.dp)
                   .background(Color.LightGray),
                   contentAlignment = Alignment.CenterStart){
                   Text(
                       text = list[it],
                       style = MaterialTheme.typography.bodyLarge,
                       modifier = Modifier
                           .fillMaxWidth()
                           .padding(horizontal = 16.dp)
                   )
               }
            }
        }
    }
}

上面代码中有25个item,同样,当列表Item数量为5的时候,其本身也是可以滑动的

fire_171.gif

五、总结

本篇自定义布局特点

  • 相比市面上的实现,代码灵活度更高
  • 层次清洗,可维护性强
  • 易于扩展
  • 耦合度低

到这里本篇就结束了,本篇涉及到两个重点,一个是ScrollConnection的用法,另一个是使用Compose的Layout组件自定义布局。

自定义Layout和ScrollConnection是必须要要掌握的知识点,为什么这么说呢,因为这就相当于工具箱和基础材料的关系。如果熟练掌握,可以更加方便的面向老板编程。

六、本篇源码

由于代码量太长,后续我们会在Github中加入

附: Github源码#SwipeRefreshActivity