22.2 Compose NestedScrollConnection 实现折叠/覆盖布局

1,784 阅读1分钟

插一下,如何在 composable 函数中获取状态栏高度

val statusBarHeightDp = with(LocalDensity.current) {
	WindowInsets.Companion.statusBars.getTop(this).toDp()
}

// 打印 WindowInsets.Companion.statusBars 结果是:
// statusBars(0, 136, 0, 0)
// AndroidWindowInsets toString() 方法实现是(左上右下)
// return "$name(${insets.left}, ${insets.top}, ${insets.right},${insets.bottom})"    

实现折叠/覆盖布局

NetedScrollConnection

Compose 中默认支持嵌套滚动。

父容器添加 Modifier.nestedScroll(connection) 后子组件产生滚动事件后会先经过 NetedScrollConnection 的 onPreScroll() 、 onPostScroll() 或 onPreFling() 、onPostFling() 方法。

这些方法的返回值代表 connection 消耗了多少事件值,剩余的事件值会继续交给子组件处理。如果 connection 全部事件值,子组件就不会触发滚动事件。

NetedScrollConnection 相关内容参考

NestedScrollConnection

nestedScroll

Jetpack compose 仿QQ音乐实现下拉刷新上拉加载更多

我们这里只用到 onPreScroll()

/*
available 可用的 事件值  
source 事件来源   Drag / Fling

返回 connect 消耗的 Offset , 默认 Offset.Zero 既不消耗
*/
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero

布局结构和最终效果

两种布局结构如下

|-布局容器(modifier = Modifier.nestedScroll(connection))
	|-Top容器
	|-Bottom容器(modifier = Modifier.fillMaxSize().scrollable(rememberScrollState(), Orientation.Vertical))

都使用 NestedScrollConnection 实现,Bottom 容器产生滑动事件后使用 NestedScrollConnection 拦截,根据事件值对 Top 或 Bottom 容器做出相应改变。

效果如下

折叠布局

Untitled.gif

覆盖布局

Untitled.gif

NetedScrollConnection 实现

首先在 connect 的 onPreScroll() 方法中判断是否让 connect 消耗掉事件值。

定义 TopStates 来辅助判断

enum class TopStates {
    EXPANDED,// 默认展开状态 
    SCROLLING,//中间状态 ,中间状态时 connection 也要消耗掉全部事件 
    COLLAPSED// 折叠状态 
}

将判断方法封装到 CollapsableLayoutState 中

class CollapsableLayoutState{
    fun shouldConsumeAvailable(available: Offset): Boolean {
      //省略
    }  
}

还需要考虑到 bottom 容器中如果有可以滚动的子组件发生滚动的情况

Untitled.gif

这种情况下 connection 不应该消耗事件

class CollapsableScrollConnection(
    private val isChildScrolled: State<Boolean> = mutableStateOf(false),
    private val state: CollapsableLayoutState
) : NestedScrollConnection {

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        if (isChildScrolled.value) return Offset.Zero
        if (state.shouldConsumeAvailable(available)) {
            state.plusOffset(available)
            return available
        }
        return Offset.Zero
    }
}

需要消耗事件的情况下,我们将变化记录在 CollapsableLayoutState 的offsetState 中,具体如何处理不在 connection 中实现。

CollapsableLayoutState

先实现上面提到的内容

class CollapsableLayoutState{
  
    private val offsetState: MutableState<Offset> = mutableStateOf(Offset.Zero)
    private val topState: MutableState<TopStates> = mutableStateOf(TopStates.EXPANDED)  
  
    fun plusOffset(offset: Offset) {
        offsetState.value = offsetState.value + offset
    }

  
    fun shouldConsumeAvailable(available: Offset): Boolean {
        return topState.value == TopStates.SCROLLING //处于折叠和展开状态
                //展开状态 向上滑动
                || topState.value == TopStates.EXPANDED && available.y < 0
                //折叠状态 向下滑动
                || topState.value == TopStates.COLLAPSED && available.y > 0
    }  
}

这两个布局效果的实现就是根据 offsetState 值的变化来改变 top 容器或 bottom 容器的高度。

  1. 这个变化的最大值是top 容器的高度 ,最小值之可以通过传参来指定。
  2. 计算变化,根据计算出的差值设置当前 topState.value 的值
  3. 计算出差值在 [最小值,最大值] 中百分比 提供一个跟随变化的 [0,1] 的 state 供外部监听实现动画
@Composable
fun rememberCollapsableLayoutState(minTopHeightDp: Dp = 0.dp): CollapsableLayoutState {
    val density = LocalDensity.current
    return remember(minTopHeightDp,density) {
        CollapsableLayoutState(minTopHeightDp, density)
    }
}

class CollapsableLayoutState(
    private val minTopHeightDp: Dp,
    density: Density
){
    private val offsetState: MutableState<Offset> = mutableStateOf(Offset.Zero)
    private val topState: MutableState<TopStates> = mutableStateOf(TopStates.EXPANDED)
    //top 最大高度需要 top 容器经过测量后得到
    private val maxHeightState: MutableState<Int> = mutableStateOf(-1)
    //默认 EXPANDED 状态 ,progress 默认为 1
    private val expendProgressState = mutableStateOf(1f)

    val currentTopState
        get() = topState.value

    val minTopHeightPx = with(density) {
        minTopHeightDp.toPx().toInt()
    }

    val maxTopHeightPx
        get() = maxHeightState.value

    val maxOffsetY
        get() = maxTopHeightPx - minTopHeightPx

    val expendProgress
        get() = expendProgressState.value

    fun shouldConsumeAvailable(available: Offset): Boolean {
        return topState.value == TopStates.SCROLLING //处于折叠和展开状态
                //展开状态 向上滑动
                || topState.value == TopStates.EXPANDED && available.y < 0
                //折叠状态 向下滑动
                || topState.value == TopStates.COLLAPSED && available.y > 0
    }

    fun plusOffset(offset: Offset) {
        offsetState.value = offsetState.value + offset
    }

    /**
     * 设置 top 容器最大高度
     * @param maxHeight Int
     */
    fun updateMaxTopHeight(maxHeight: Int) {
        if (maxHeight == maxHeightState.value) return
        maxHeightState.value = maxHeight
    }
    
    /*
       根据 offsetState 计算当前 top 容器的高度
     */
    fun calcTopHeight():Int{
        val curTopHeight = (maxTopHeightPx + offsetState.value.y.toInt()).coerceIn(minTopHeightPx,maxTopHeightPx)
        //根据 curTopHeight 设置 LayoutState ,计算 expendProgressState 
        when (curTopHeight) {
            minTopHeightPx -> {
                offsetState.value = Offset(0f, -maxOffsetY.toFloat())
                expendProgressState.value = 0f
                updateLayoutState(TopStates.COLLAPSED)
            }
            maxTopHeightPx -> {
                offsetState.value = Offset.Zero
                expendProgressState.value = 1f
                updateLayoutState(TopStates.EXPANDED)
            }
            else -> {
                val offsetY = (maxTopHeightPx - curTopHeight).toFloat()
                expendProgressState.value = 1 - offsetY / maxOffsetY
                updateLayoutState(TopStates.SCROLLING)
            }
        }
        return curTopHeight
    }

    private fun updateLayoutState(state:TopStates){
        if (state == topState.value) return
        topState.value = state
    }
}

折叠布局实现

使用 Column 最为父容器, 根据 CollapsableLayoutState 计算后 top 的高度来改变 top 容器的大小 , bottom 容器在 fillMaxSize 的情况下也会随之改变。

@Composable
fun CollapsableLayout(
    topContent: @Composable () -> Unit,
    bottomContent: @Composable () -> Unit,
    bottomContentScrolled: State<Boolean> = mutableStateOf(false),
    state: CollapsableLayoutState = rememberCollapsableLayoutState(0.dp)
) {

    val connection: CollapsableScrollConnection = remember {
        CollapsableScrollConnection(bottomContentScrolled, state)
    }

    val heightModifier = if (state.maxTopHeightPx != -1) {
        Modifier.height(with(LocalDensity.current){
            state.calcTopHeight().toDp()
        })
    } else {
        Modifier
    }

    Column(modifier = Modifier
        .nestedScroll(connection)
    ) {
        Box(
            modifier = Modifier.then(heightModifier)
                .onSizeChanged {
          			//设置 top 最大高度
                    if (state.maxTopHeightPx == -1) {
                        state.updateMaxTopHeight(it.height)
                    }
                }
        ) { topContent() }

        Box(
            modifier = Modifier.fillMaxSize()
                .scrollable(rememberScrollState(), Orientation.Vertical)
        ) { bottomContent() }
    }
}

覆盖布局实现

使用 Layout 做为父容器,实现 MeasurePolicy , 根据 CollapsableLayoutState 计算后 top 的高度来对 bottom 容器进行测绘和布局达到覆盖的效果

@Composable
fun CoverLayout(
    topContent: @Composable BoxScope.()-> Unit,
    bottomContent: @Composable BoxScope.() -> Unit,
    bottomContentScrolled: State<Boolean> = mutableStateOf(false),
    state:CollapsableLayoutState = rememberCollapsableLayoutState()
) {

    val connection: CollapsableScrollConnection = remember {
        CollapsableScrollConnection(bottomContentScrolled, state)
    }

    Layout(modifier = Modifier
        .nestedScroll(connection), content = {
        Box(content = topContent )
        Box(
            modifier = Modifier
                .fillMaxSize()
                .scrollable(rememberScrollState(), Orientation.Vertical)
                .background(Color.Green),
            content = bottomContent
        )
    }) { measurables, constraints ->
        val placeableTop = measurables[0].measure(constraints)
        if (state.maxTopHeightPx == -1){
            state.updateMaxTopHeight(placeableTop.height)
        }
        val topHeight = state.calcTopHeight()
        //bottom 的最大高度约束要减去 top 容器的高度
        val bottomConstraints =
            constraints.copy(maxHeight = constraints.maxHeight -  topHeight)

        val placeableBottom = measurables[1].measure(bottomConstraints)
        
        layout(constraints.maxWidth, constraints.maxHeight) {
            placeableTop.placeRelative(0, 0)
            //bottom 容器放在 top 之下
            placeableBottom.placeRelative(0, topHeight)
        }
    }
}

使用

@Composable
fun Test() {

    val listState = rememberLazyListState()
    // bottom 中 LazyColumn 内容是否滚动过
    val bottomContentScrolled: State<Boolean> = remember {
        derivedStateOf {
            !(listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0)
        }
    }
    val collapsableLayoutState = rememberCollapsableLayoutState()

    CollapsableLayout(

        topContent = {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    //根据 expendProgress 设置 alpha 动画效果
                    .alpha(collapsableLayoutState.expendProgress)
            ){
                Image(
                    modifier = Modifier.fillMaxSize(),
                    painter = painterResource(id = R.drawable.c19e2e81da3ede74c24c29bf6b1a800b),
                    contentScale = ContentScale.FillBounds,
                    contentDescription =""
                )
            }
        },

        bottomContent = {

            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .navigationBarsPadding(),
                state = listState,
                verticalArrangement = Arrangement.spacedBy(22.dp)
            ) {
                items(30) {
                    Text(text = "第 $it 项")
                }
            }
        },
        bottomContentScrolled = bottomContentScrolled,
        state = collapsableLayoutState
    )
}

源码 git 地址