前言
Android中CoordinatorLayout
是一个很常用的布局,一些特殊交互如吸顶,用它实现非常简单,但Compose中目前没有这个组件。
如果只是单纯吸顶的交互,可以用LazyColumn
的stickyHeader
来实现,非常简单。但如果要控制吸顶的位置,如下面的效果图,stickyHeader
就爱莫能助了,而且LazyColumn
监听滑动的进度也比较麻烦。
于是决定撸一个Compose版本的CoordinatorLayout 💪🏻💪🏻💪🏻
效果图
使用
使用起来很简单,具体可查看完整代码。
点击查看完整代码
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
。这里我们用到了onPreScroll
和onPostScroll
。下面简单说明下这两个方法,熟悉的同学可以跳过。
onPreScroll
onPreScroll
方法用于在子视图即将滚动时预先消费部分或全部滚动事件。
方法签名:
kotlin
fun onPreScroll(available: Offset, source: NestedScrollSource): Offset
参数说明:
available: Offset
:表示当前可用的滚动偏移量,包含x和y方向的值。source: NestedScrollSource
:表示滚动事件的来源,例如Drag
或Fling
。
返回值:
- 返回一个
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
已经滑动到最顶部了,此时只需要消费的剩余的滚动偏移量,让Collapsable
和Pin
往下移动即可。
是的,你没看错,就是这么简单!!!😄😄😄
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
可以没有,所以判断一下。 可以注意到测量和Collapsable
和Pin
时传入的maxHeight = Constraints.Infinity
,这意味着子组件的高度没有明确的限制,子组件可以根据自身内容或布局逻辑自由扩展高度,这是因为我们的CoordinatorLayout
是内容可滑动的。
nonCollapsableHeight
设置不进行折叠的高度,当剩余的可折叠高度达到这个值的时候,再继续滑动,Collapsable
和Pin
便不再跟随滑动了,从而实现,在指定的位置吸顶,常用语吸附在标题栏下方,如前面的效果图。这个可以根据测量的结果动态设置,具体见demo。
我们希望Content
撑满剩余的高度,所以测量的时候,constraints
的是maxHeight
设置的是:
(height - pinContentHeight - safeNonCollapsableHeight).coerceAtLeast(0))
maxCollapsableHeight
最大可折叠高度maxCollapsableHeight
就是collapsable
的高度 - 不可折叠的高度safeNonCollapsableHeight
layout
前面测量了3个Content
的尺寸,这里layout
就很简单了。只需要根据当前折叠的高度collapsedHeight
,摆放即可,这里很好理解,不做过多的解释了。
处理Fling
通过上面的步骤,我们已经实现了基本的功能。但其中仍然有一些问题,需要处理。 比如Android
的CoordinatorLayout
中存在的一个问题,在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
默认的flingBehavior
是ScrollableDefaults.flingBehavior()
。这里我们为 scrollable
设置一个自定义的FlingBehavior
,在performFling
方法中首先让默认的flingBehavior
去执行performFling
方法,去让scrollable
消费Fling
。scrollable
消费完后,判断如果是向上(Content
只需要关注向上的Fling),则交由Content
的nestedScrollableState
去消费即可。
反馈
目前collapsableContent
的滑动效果比较简单,只支持跟随滑动。
大家有什么优化建议、bug反馈,欢迎大家指出、反馈 👏🏻👏🏻👏🏻 → [email protected]