用Compose撸一个CoordinatorLayout 🔥🔥🔥

前言

Android中CoordinatorLayout是一个很常用的布局,一些特殊交互如吸顶,用它实现非常简单,但Compose中目前没有这个组件。

如果只是单纯吸顶的交互,可以用LazyColumnstickyHeader来实现,非常简单。但如果要控制吸顶的位置,如下面的效果图,stickyHeader就爱莫能助了,而且LazyColumn监听滑动的进度也比较麻烦。

于是决定撸一个Compose版本的CoordinatorLayout 💪🏻💪🏻💪🏻

Github地址

效果图

使用

使用起来很简单,具体可查看完整代码。
点击查看完整代码

kotlin 复制代码
    // collapsable + pin + LazyColumn
@Composable
fun SimpleScreen2() {
    Column(
        Modifier
            .fillMaxSize()
            .background(Color.White)
            .systemBarsPadding()
    ) {
        val coroutineScope = rememberCoroutineScope()
        val lazyListState = rememberLazyListState()
        val coordinatorState = rememberCoordinatorState()
        var uiState by remember { mutableStateOf(DemoState()) }

        Box(
            modifier = Modifier
                .height(50.dp)
                .fillMaxWidth()
                .padding(horizontal = 20.dp),
            contentAlignment = Alignment.CenterStart
        ) {
            DemoTitle()
        }

        HorizontalDivider(color = AppColors.Divider)

        CoordinatorLayout(
            nestedScrollableState = { lazyListState },
            state = coordinatorState,
            modifier = Modifier.fillMaxSize(),
            collapsableContent = {
                Column(Modifier.fillMaxWidth()) {
                    Image(
                        painter = painterResource(id = R.mipmap.img_1),
                        contentDescription = null,
                        modifier = Modifier.fillMaxWidth(),
                        contentScale = ContentScale.FillWidth
                    )
                }
            },
            pinContent = {
                TabBar(
                    tabList = uiState.tabList,
                    selectedTabIndex = uiState.selectedTab,
                ) {
                    // 吸顶
                    coroutineScope.launch {
                        uiState = uiState.copy(selectedTab = it)
                        coordinatorState.animateToCollapsed()
                    }

                }
            },
        ) {
            LazyColumn(Modifier.fillMaxSize(), state = lazyListState) {
                items(30) {
                    Box(
                        Modifier
                            .fillMaxWidth()
                            .height(50.dp)
                            .padding(horizontal = 15.dp),
                        contentAlignment = Alignment.CenterStart
                    ) {
                        Text(
                            text = "Item $it",
                            textAlign = TextAlign.Center,

                            )
                        HorizontalDivider(
                            thickness = 0.7.dp,
                            color = AppColors.Divider,
                            modifier = Modifier.align(Alignment.BottomStart)
                        )

                    }
                }

            }
        }
    }

}

实现

接下来,着重讲一下实现过程。一起体验一下Compose的丝滑😄😄😄

1、CoordinatorState记录当前/最大滑动距离

先附上CoordinatorState的完整代码。
点击查看完整代码

kotlin 复制代码
@Composable
fun rememberCoordinatorState(): CoordinatorState {
    return rememberSaveable(saver = CoordinatorState.Saver) { CoordinatorState() }
}

@Stable
class CoordinatorState {
    // 已折叠的高度
    var collapsedHeight: Float by mutableFloatStateOf(0f)
        private set

    var isFullyCollapsed by mutableStateOf(false)
        private set

    private var _maxCollapsableHeight = mutableFloatStateOf(Float.MAX_VALUE)

    // 最大可折叠高度
    var maxCollapsableHeight: Float
        get() = _maxCollapsableHeight.floatValue
        internal set(value) {
            if (value.isNaN()) return
            _maxCollapsableHeight.floatValue = value
            Snapshot.withoutReadObservation {
                if (collapsedHeight >= value) {
                    collapsedHeight = value
                    isFullyCollapsed = true
                } else if (isFullyCollapsed){
                    collapsedHeight = value
                }
            }

        }

    val scrollableState = ScrollableState { // 向上滑动,为负的,
        val newValue = (collapsedHeight - it).coerceIn(0f, maxCollapsableHeight)
        val consumed = collapsedHeight - newValue
        collapsedHeight = newValue
        isFullyCollapsed = newValue == maxCollapsableHeight
        consumed
    }

    // animTo 完全折叠状态
    suspend fun animateToCollapsed(
        animationSpec: AnimationSpec<Float> = tween(
            100,
            easing = LinearEasing
        )
    ) {
        animateScrollBy(-(maxCollapsableHeight - collapsedHeight), animationSpec)
    }

    suspend fun animateScrollBy(
        value: Float, animationSpec: AnimationSpec<Float> = tween(
            100,
            easing = LinearEasing
        )
    ) {
        scrollableState.animateScrollBy(value, animationSpec)
    }

    private fun consume(available: Offset): Offset {
        val consumedY = scrollableState.dispatchRawDelta(available.y)
        return available.copy(y = consumedY)
    }


    internal val nestedScrollConnection = object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            // 水平方向不消耗
            if (available.x != 0f) return Offset.Zero
            // 向上滑动,如果没有达到最大可折叠高度,则自己先消耗
            if (available.y < 0 && collapsedHeight < maxCollapsableHeight) {
                return consume(available)
            }

            return Offset.Zero
        }

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            if (available.y > 0) {
                return consume(available)
            }
            return Offset.Zero
        }
    }

    companion object {
        val Saver: Saver<CoordinatorState, *> = Saver(
            save = { listOf(it.collapsedHeight, it.maxCollapsableHeight) },
            restore = {
                CoordinatorState().apply {
                    collapsedHeight = it[0]
                    maxCollapsableHeight = it[1]
                    isFullyCollapsed = collapsedHeight >= maxCollapsableHeight
                }
            }
        )
    }
}

CoordinatorState中定义了已折叠高度collapsedHeight以及最大可折叠高度maxCollapsableHeight

创建一个ScrollableState用于滑动,更新collapsedHeight

kotlin 复制代码
val scrollableState = ScrollableState { // 向上滑动,为负的,
    val newValue = (collapsedHeight - it).coerceIn(0f, maxCollapsableHeight)
    val consumed = collapsedHeight - newValue
    collapsedHeight = newValue
    isFullyCollapsed = newValue == maxCollapsableHeight
    consumed
}

2、处理滑动

Compose处理滑动冲突非常简单,核心类NestedScrollConnection。这里我们用到了onPreScrollonPostScroll。下面简单说明下这两个方法,熟悉的同学可以跳过。

onPreScroll

onPreScroll方法用于在子视图即将滚动时预先消费部分或全部滚动事件。

方法签名:

kotlin 复制代码
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset

参数说明:

  • available: Offset:表示当前可用的滚动偏移量,包含x和y方向的值。
  • source: NestedScrollSource:表示滚动事件的来源,例如DragFling

返回值:

  • 返回一个Offset,表示当前组件消费的滚动偏移量。如果不想消费任何滚动事件,可以返回Offset.Zero

onPostScroll

onPostScroll 方法用于在子组件已经消费了滚动事件之后,处理剩余的滚动偏移量。

方法签名:

kotlin 复制代码
fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset

参数说明:

  • consumed: Offset:表示子组件已经消费的滚动偏移量。
  • available: Offset:表示当前剩余的可用滚动偏移量。
  • source: NestedScrollSource:表示滚动事件的来源。

返回值:

  • 返回一个 Offset,表示当前组件消费的滚动偏移量。如果不想消费任何滚动事件,可以返回 Offset.Zero

回到我们的代码。首先看一下我们的CoordinatorLayout的结构图。

CoordinatorLayout由3部分组成,CollapsableContent(以下简称Collapsable)可折叠的内容,PinContent(以下简称Pin),吸顶的内容,Content底部区域。

页面向上滑动时,如果Collapsable没有完全折叠,会优先响应滚动,直到完全折叠状态,Pin吸顶。然后继续上滑,由Content来响应滑动。下拉时相反。

好了,接下来我们详细说一下这两个方法中的实现。

kotlin 复制代码
private fun consume(available: Offset): Offset {
    val consumedY = scrollableState.dispatchRawDelta(available.y)
    return available.copy(y = consumedY)
}


internal val nestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        // 水平方向不消耗
        if (available.x != 0f) return Offset.Zero
        // 向上滑动,如果没有达到最大可折叠高度,则自己先消耗
        if (available.y < 0 && collapsedHeight < maxCollapsableHeight) {
            return consume(available)
        }

        return Offset.Zero
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        if (available.y > 0) {
            return consume(available)
        }
        return Offset.Zero
    }
}

onPreScroll中,首先我们不需要消费水平方向的滑动,为了避免影响水平方向的滑动事件,这里判断如果有水平偏移(available.x != 0f),直接不返回Offset.Zero不消费。如果是向上滑动,需要判断Collapsable是否已经完全折叠,如果没有,则优先自己消费,否则不消费。

onPostScroll方法回调,意味着Content已经消费了滚动事件。比如Content是个LazyColumn,这里只需要关注向下滑动。该方法回调意味着,LazyColumn已经滑动到最顶部了,此时只需要消费的剩余的滚动偏移量,让CollapsablePin往下移动即可。

是的,你没看错,就是这么简单!!!😄😄😄

3、自定义Layout

接下来,我们分析下CoordinatorLayout的实现。同样,先贴出完整的代码。
点击查看完整代码

kotlin 复制代码
/**
 * @param collapsableContent 可折叠的Content
 * @param pinContent 要吸顶的Content,默认为空的
 * @param content 底部的Content
 * @param nonCollapsableHeight 不允许折叠的高度,至少为0
 * @param nestedScrollableState 用于collapsableContent和pinContent快速滑动,完全折叠后,剩余Fling交给content来响应。如果不设置,完全折叠后,content不能响应剩余Fling
 *
 */
@Composable
fun CoordinatorLayout(
    nestedScrollableState: () -> ScrollableState?,
    collapsableContent: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    pinContent: @Composable () -> Unit = {},
    state: CoordinatorState = rememberCoordinatorState(),
    nonCollapsableHeight: Int = 0,
    content: @Composable () -> Unit
) {
    check(nonCollapsableHeight >= 0) {
        "nonCollapsableHeight is at least 0!"
    }

    val flingBehavior = ScrollableDefaults.flingBehavior()
    Layout(
        content = {
            collapsableContent()
            pinContent()
            content()
        }, modifier = modifier
            .clipToBounds()
            .fillMaxSize()
            .scrollable(
                state = state.scrollableState,
                orientation = Orientation.Vertical,
                enabled = !state.isFullyCollapsed,
                flingBehavior = remember {
                    object : FlingBehavior {
                        override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
                            val remain = with(flingBehavior) {
                                performFling(initialVelocity)
                            }
                            // 外层响应Fling后,剩余的交给nestedScrollableState来处理
                            if (remain < 0 && nestedScrollableState() != null) { // 向上滑动,剩余的Fling交给nestedScrollableState消费
                                nestedScrollableState()!!.scroll {
                                    with(flingBehavior){
                                        performFling(-remain)
                                    }
                                }
                                return 0f
                            }
                            return remain
                        }
                    }
                },
            )
            .nestedScroll(state.nestedScrollConnection)
    ) { measurables, constraints ->
        check(constraints.hasBoundedHeight)
        val height = constraints.maxHeight
        val collapsablePlaceable = measurables[0].measure(
            constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
        )
        val collapsableContentHeight = collapsablePlaceable.height
        val pinPlaceable: Placeable? = if (measurables.size == 3) {
            measurables[1].measure(
                constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
            )
        } else null
        val pinContentHeight = pinPlaceable?.height ?: 0
        val safeNonCollapsableHeight = nonCollapsableHeight.coerceAtMost(collapsableContentHeight)
        val nestedScrollPlaceable = measurables[measurables.lastIndex].measure(
            constraints.copy(
                minHeight = 0,
                maxHeight = (height - pinContentHeight - safeNonCollapsableHeight).coerceAtLeast(0)
            )
        )
        state.maxCollapsableHeight =
            (collapsablePlaceable.height - safeNonCollapsableHeight).toFloat().coerceAtLeast(0f)
        layout(constraints.maxWidth, height) {
            val collapsedHeight = state.collapsedHeight.roundToInt()
            nestedScrollPlaceable.placeRelative(0, collapsableContentHeight + pinContentHeight - collapsedHeight)
            collapsablePlaceable.placeRelative(0, -collapsedHeight)
            pinPlaceable?.placeRelative(0, collapsableContentHeight - collapsedHeight)
        }
    }
}
    

这里我们先不需要关注flingBehavior,下面会单独解释。

scrollable

这里可以看到,我们给Layout设置了scrollable,并传入CoordinatorState中的ScrollableState并指明orientation,来使得我们的CoordinatorLayout支持竖直方向可滑动。

nestedScroll

nestedScroll用于处理嵌套滑动。传入我们CoordinatorState中的nestedScrollConnection

measure

解释measure之前,先看下check(constraints.hasBoundedHeight)的作用。CoordinatorLayout的高度有一个明确的上限。

先测量3个Content的尺寸,由于Pin可以没有,所以判断一下。 可以注意到测量和CollapsablePin时传入的maxHeight = Constraints.Infinity,这意味着子组件的高度没有明确的限制,子组件可以根据自身内容或布局逻辑自由扩展高度,这是因为我们的CoordinatorLayout是内容可滑动的。

nonCollapsableHeight

设置不进行折叠的高度,当剩余的可折叠高度达到这个值的时候,再继续滑动,CollapsablePin便不再跟随滑动了,从而实现,在指定的位置吸顶,常用语吸附在标题栏下方,如前面的效果图。这个可以根据测量的结果动态设置,具体见demo。

我们希望Content撑满剩余的高度,所以测量的时候,constraints的是maxHeight设置的是:

(height - pinContentHeight - safeNonCollapsableHeight).coerceAtLeast(0))

maxCollapsableHeight

最大可折叠高度maxCollapsableHeight就是collapsable的高度 - 不可折叠的高度safeNonCollapsableHeight

layout

前面测量了3个Content的尺寸,这里layout就很简单了。只需要根据当前折叠的高度collapsedHeight,摆放即可,这里很好理解,不做过多的解释了。

处理Fling

通过上面的步骤,我们已经实现了基本的功能。但其中仍然有一些问题,需要处理。 比如AndroidCoordinatorLayout中存在的一个问题,在AppBarLayout上快速向上滑动到吸顶后,底部的nestedContent无法继续响应Fling等,我们下面一一解决。

kotlin 复制代码
val flingBehavior = ScrollableDefaults.flingBehavior()
flingBehavior = remember {
    object : FlingBehavior {
        override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
            val remain = with(flingBehavior) {
                performFling(initialVelocity)
            }
            // scrollable消费Fling后,剩余的交给nestedScrollableState来处理
            if (remain < 0 && nestedScrollableState() != null) { // 向上滑动,剩余的Fling交给nestedScrollableState消费
                nestedScrollableState()!!.scroll {
                    with(flingBehavior){
                        performFling(-remain)
                    }
                }
                return 0f
            }
            return remain
        }
    }
}
    

scrollable 默认的flingBehaviorScrollableDefaults.flingBehavior()。这里我们为 scrollable设置一个自定义的FlingBehavior,在performFling方法中首先让默认的flingBehavior去执行performFling方法,去让scrollable消费Flingscrollable消费完后,判断如果是向上(Content只需要关注向上的Fling),则交由ContentnestedScrollableState去消费即可。

反馈

目前collapsableContent的滑动效果比较简单,只支持跟随滑动。

大家有什么优化建议、bug反馈,欢迎大家指出、反馈 👏🏻👏🏻👏🏻 → [email protected]

相关推荐
时光少年3 分钟前
Android 副屏录制方案
android·前端
时光少年12 分钟前
Android 局域网NIO案例实践
android·前端
alexhilton30 分钟前
Jetpack Compose的性能优化建议
android·kotlin·android jetpack
流浪汉kylin36 分钟前
Android TextView SpannableString 如何插入自定义View
android
好学人2 小时前
remember 的核心特性及用法
android jetpack
火柴就是我2 小时前
git rebase -i,执行 squash 操作 进行提交合并
android
你说你说你来说3 小时前
安卓广播接收器(Broadcast Receiver)的介绍与使用
android·笔记
你说你说你来说3 小时前
安卓Content Provider介绍及使用
android·笔记
RichardLai883 小时前
[Flutter学习之Dart基础] - 类
android·flutter
_一条咸鱼_4 小时前
深度解析 Android MVI 架构原理
android·面试·kotlin