用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]

相关推荐
QING6181 小时前
一文带你了解Android中常见的跨组件通信方案及其适用场景
android·架构·app
hrrrrb1 小时前
【MySQL】多表操作 —— 外键约束
android·数据库·mysql
QING6181 小时前
一文带你吃透接口(Interface)结合 @AutoService 与 ServiceLoader 详解
android·kotlin·app
二十四桥明月夜ya1 小时前
如何配置Clion编写aosp的c++程序
android·intellij idea
QING6181 小时前
一文带你吃透Android 中 AIDL 与 bindService 的核心区别
android·kotlin·app
九思x3 小时前
解决 Android Studio “waiting for all target devices to come online“ 卡住问题
android·ide·android studio
QING6185 小时前
Android 冷启动优化实践:含主线程优化、资源预加载与懒加载、跨进程预热等
android·性能优化·app
砖厂小工5 小时前
Now In Android 精讲 7 - 你的代码谁来守护?
android
浩宇软件开发5 小时前
Androidstudio实现一个app引导页(超详细)
android·android studio·android开发
QING6185 小时前
Android ContentProvider 详解及结合 Jetpack Startup 的优化实践
kotlin·app·android jetpack