前言
本文将会讲解什么
Modifier
封装演练Modifier.nestedScroll
的又一次具体应用(找找我此前的文章?)Composable
撰写过程中,怎样尽可能避免不必要的对象创建rememberUpdatedState
使用场景derivedStateOf
使用场景
- 使用 扩展函数 简化调用
Modifier.layout
的简单使用
本文核心实现什么
本文将实现的是一个以 nestedScroll
为核心的定制化 Modifier
,其可以正确响应 child 和 parent 的 nestedScroll 行为。
具体有以下特性:
- 组件分为 Hide、Half、Full 三种状态,可代码或者通过手势切换。
- 存在 verticalScroll 行为的child,都可和它互相响应。
- 可打断的动画,状态间随便切换。
- child 通过拖拽 drag 滚动到尽头时,将带动本组件进行"滚动"。
- child 通过投掷 fling 滚动到尽头时,也能用正确的速度带动本组件进行滚动。
- 投掷速度(即松手时的速度)低于"切换状态要求的速度"时,组件将运动到最近的一个位置状态。
空口无凭,看效果:

艹,你这效果千万别被我们产品看到了!
CoordinatorLayout 是神?
要我说,放屁!
若嵌套、bug永无止息
若实现、秃头于我何异
若修改、传参继承皆孽
若定制、梦幻泡影空虚
咳咳抖个机灵
咱们先来说说 CoordinatorLayout 为什么不是神。
若嵌套、bug永无止息
咱们看看其 类声明:
java
public class CoordinatorLayout
extends ViewGroup
implements NestedScrollingParent2,
NestedScrollingParent3 { ... }
看到了吧,压根不支持你作为 嵌套子项 ( nestedChild )。
不支持的东西你强行嵌套上去,你说:bug是不是可以预期的"永无止息"。
因为 nestedScroll 相关操作必须用匹配的 nestedScroll 系列接口去实现,只通过 scroll 等接口 + 定制手势 去尝试兼容的话,往往会是耗费无数精力和代价后,bug 还是修不完。
若实现、秃头于我何异
CoordinatorLayout 的 child 需要 behavior 才能联动起来。
------ 用作例子的 behavior 当然是本文将实现的 BottomSheeBehavior
。
区区 2275 行而已。
我相信各位一定有强大的精力、毅力、耐力、意志力,去消化它、吸收它!
我没有。
反正我是看都懒得看。
若修改、传参继承皆孽
假设我们要修改一个默认行为 ------
java
/**
* Checks weather half expended state should be skipped when drag is ended. If {@code true}, the
* bottomSheet will go to the next closest state.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
public boolean shouldSkipHalfExpandedStateWhenDragging() {
return false;
}
根据注释内容:我期望特定情况下跳过"HalfExpandedState"。
人家接口都提供出来了,但不让我们修改行为,行,你真行。
- 那我继承一下不就能修改掉了?
Too young too simple ,sometimes native !
我们在 CoordinatorLayout 中可以找到 behavior的绑定流程:
java
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
这个LayoutParams是内部类,再跟进去:
java
LayoutParams(@NonNull Context context, @Nullable AttributeSet attrs) {
// 省略无关内容...
if (mBehaviorResolved) {
mBehavior = parseBehavior(context, attrs, a.getString(
R.styleable.CoordinatorLayout_Layout_layout_behavior));
}
// ...
}
这个parseBehavior命名太规范了,一看就知道它要干啥是吧,我跟:
java
static Behavior parseBehavior(Context context, AttributeSet attrs, String name) {
// ...
try {
// 这try块的内容我都懒得看...
} catch (Exception e) {
// 看到没:"Could not inflate Behavior subclass"
throw new RuntimeException("Could not inflate Behavior subclass " + fullName, e);
}
}
看到没:无法 inflate Behavior 的子类!
你若继承,我必崩溃。
很简单,我们可以复制整个behavior的两千行代码,生成一个新class,然后自行修改里面的内容就完事了嘛。
↑↑↑ 上面这想法太幽默了对不对 ↑↑↑
吃透这两千行代码再改------造孽啊,干嘛跟自己过不去啊。
但不吃透代码就乱修改------造孽啊,你这是堂而皇之地拉屎啊!
若定制、梦幻泡影空虚
你自己定制几乎是做梦!
不如看看我这篇文章: Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!
(compose的嵌套滚动效果实现这么简单,干嘛非在一堆坑的view里面玩呢?)
(反正嵌套滚动都是要学,干嘛不学更简单、更直观的呢?)
Compose 为什么是神?
不讲废话,直接讲实现思路。
成神之道,就在其中
基本数据结构和声明
数据结构
kotlin
// 三种状态,很好理解吧
enum class PageHeightState {
Full, Half, Hide
}
核心声明
kotlin
/**
* 嵌套滚动底栏
* @param targetState 底栏的目标状态
* @param onStateChange 底栏状态改变时的回调
* @param minVelocityDp 投掷速度大于多少dp/s就能切换到对应方向的下一个状态
* @param maxHeightPx 最大高度,px,传值<=0,将自动获取最大可用高度。默认传0
*/
fun Modifier.nestedAsBottomSheet(
targetState: PageHeightState = PageHeightState.Half,
onStateChange: (PageHeightState) -> Unit = {},
minVelocityDp: Dp = 800.dp,
maxHeightPx: Int = 0,
): Modifier = composed {
// 必声明,除非你完全不想和 parent 互动
val dispatcher = remember { NestedScrollDispatcher() }
// ...
}
实现思路
核心规则
我们根据场景,很容易整理出这样一个状态变化的【规则】:
- v:松手时的速度
- minV:即上面声明的
minVelocityDp
compose 的最优越之处就在于:我们只需要关注规则、然后撰写规则。
值得注意的是:这里的 4 个 Area 是均分的,这样做不一定符合用户的心理预期,可结合实际情况自行调整4块区域的覆盖范围。
整体脉络
根据上面整理,我们又可以进一步填充我们的代码了------
kotlin
// 稍后解释这个
@Composable
fun <T> rememberUpdatedMutableState(newValue: T): MutableState<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
/**
* 嵌套滚动底栏
* @param targetState 底栏的目标状态
* @param onStateChange 底栏状态改变时的回调
* @param minVelocityDp 投掷速度大于多少dp/s就能切换到对应方向的下一个状态
* @param maxHeightPx 最大高度,px,传值<=0,将自动获取最大可用高度。默认传0
*/
fun Modifier.nestedAsBottomSheet(
targetState: PageHeightState = PageHeightState.Half,
onStateChange: (PageHeightState) -> Unit = {},
minVelocityDp: Dp = 800.dp,
maxHeightPx: Int = 0,
): Modifier = composed {
// 这个不必多说
val dispatcher = remember { NestedScrollDispatcher() }
val height = remember { Animatable(0f) }
val density = LocalDensity.current
// 最大高度的指定,和兜底赋值
// 想想看,为啥要用rememberUpdatedState?
val maxHeightFloat by rememberUpdatedState((if (maxHeightPx <= 0f) LocalConfiguration.current.screenHeightDp * density.density else maxHeightPx).toFloat())
// 以下4个定义都是派生定义,所以用 derivedStateOf
val threeQuartersHeight by remember { derivedStateOf { maxHeightFloat * 3 / 4f } }
val halfHeight by remember { derivedStateOf { maxHeightFloat / 2f } }
val quarterHeight by remember { derivedStateOf { maxHeightFloat / 4f } }
val transStateToHeight: (PageHeightState) -> Float by remember {
derivedStateOf {
// 注意:
// 此处省略了 `return` ,返回的是一个 lambda
{ pageState ->
// 注意:
// 此 lambda 也省略了 `return`
// 返回的是一个 float
when (pageState) {
PageHeightState.Full -> maxHeightFloat
PageHeightState.Half -> halfHeight
PageHeightState.Hide -> 0f
}
}
}
}
val minVelocity by rememberUpdatedState(minVelocityDp)
val springAnim = remember { spring(1f, 200f, 1f) }
// 注意:
// 这里使用的是上面新定义的一个 Composable
var lastNotifiedState by rememberUpdatedMutableState(targetState)
// 想想这里为啥是一个 rememberUpdatedState
val notifyStateChange: State<(PageHeightState) -> Unit> = rememberUpdatedState { newState ->
if (lastNotifiedState != newState) {
dispatcher.coroutineScope.launch { onStateChange(newState) }
lastNotifiedState = newState
}
}
// 注意:
// 这个 remember{} 未带任何key
// 但它内部仍能取得上面最新的变量/回调
val nestedConnection = remember {
object : NestedScrollConnection {
// 调用这个,不比function调用来得优雅?!
private val Float.isInBalance: Boolean
get() = this == maxHeightFloat || this == halfHeight || this == 0f
private val Float.isNotInBalance: Boolean
get() = !isInBalance
// 注意:
// 这其实是一个变量,每次调用都会重新计算
private val minSwitchVelocity
get() = density.density * minVelocity.value
// 这就是"我该去哪儿"的核心判断方法:
// 根据当前高度和速度判断我的目标状态
private fun computePageHeightState(curHeight: Float, v: Float): PageHeightState {
val pageState: PageHeightState = when (curHeight) {
in threeQuartersHeight..maxHeightFloat -> if (v > minSwitchVelocity) PageHeightState.Half else PageHeightState.Full
in halfHeight..threeQuartersHeight -> if (v < -minSwitchVelocity) PageHeightState.Full else PageHeightState.Half
in quarterHeight..halfHeight -> if (v > minSwitchVelocity) PageHeightState.Hide else PageHeightState.Half
in 0f..quarterHeight -> if (v < -minSwitchVelocity) PageHeightState.Half else PageHeightState.Hide
else -> PageHeightState.Full
}
return pageState
}
// 这几个老熟人肯定不着急讲
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {}
override suspend fun onPreFling(available: Velocity): Velocity {}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {}
}
}
// 传参state一旦改变,立马animate到对应新状态!
LaunchedEffect(targetState) {
height.animateTo(transStateToHeight(targetState), springAnim)
}
this
.nestedScroll(nestedConnection, dispatcher)
.layout { measurable, constraints ->
val targetHeight = height.value.roundToInt().coerceAtLeast(0)
// 用自己定义的constrainsts去measure
// 使得child、parent能正确布局
val placeable = measurable.measure(
constraints.copy(
minHeight = targetHeight,
maxHeight = targetHeight)
)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
脉络拆分之:Modifier.layout
本文实现该效果并没有参照 BottomSheetBehavior 做 offset。 而是实现的 重新layout 。
因为我喜欢啃硬骨头。
------ 因为 compose 这个 layout ,大家刚接触肯定有点懵,可能简单两行、但半天达不到想要的效果。我就做个好人,帮大家顺便讲了。
------ 况且,滑动时改变布局大小,而不是简单位移布局内容,也必然是大家的需求之一。
- 如果使用 offset 实现此效果,则必然要面对"底栏得另行布局"的问题。
- 这时候就会存在整体高度需要重新设置,要减去底栏高度
- 那......底栏高度如果是动态的呢?
- 哦豁,md,想想就麻烦。
- 什么辣鸡需求,劳资不干了!
- 哦豁,md,想想就麻烦。
- 那......底栏高度如果是动态的呢?
- 这时候就会存在整体高度需要重新设置,要减去底栏高度
- 如果使用 layout 实现此效果
- 则布局可以这么写:
kotlin
@Composable
private fun HalfPage(modifier: Modifier, minVelocityDp: Dp, pageHeightState: () -> PageHeightState, onStateChange: (PageHeightState) -> Unit) {
// 半屏页面
Box(modifier
.fillMaxWidth()
.nestedAsBottomSheet(pageHeightState(), onStateChange, minVelocityDp)
) {
Column(Modifier.fillMaxWidth().align(Alignment.BottomCenter)) {
val lazyListState = rememberLazyListState()
LazyColumn(Modifier.fillMaxWidth()
// 看到没,动态高度,尽可能占满
.weight(1f)
) {
// ...
}
// 充当底部栏,底部栏不用的空间留给上面的weight(1f)的控件
Column(Modifier.fillMaxWidth().padding(vertical = 15.dp)) {
// balabalabalh
}
}
}
}
综上,layout 的实现、优势讲解完毕。
脉络拆分之:rememberUpdatedState
首先这个在啥时候用?
我们当然可以看官方怎么做的:
kotlin
// androidx.compose.material3:material3:1.1.1@aar
// Slider.kt
@Composable
private fun SliderImpl(
// ...
onValueChange: (Float) -> Unit,
// ...
) {
val onValueChangeState = rememberUpdatedState<(Float) -> Unit> {
if (it != value) {
onValueChange(it)
}
}
// ...
val draggableState = remember(valueRange) {
SliderDraggableState {
// ...
onValueChangeState.value.invoke(scaleToUserValue(minPx, maxPx, offsetInTrack))
}
}
可以看到:
下方 draggableState 对象的 rememeber key 只有一个"valueRange"。
也就是说:仅 valueRange 改变了,才会导致此对象重新创建;
也就是说:它内部拿到的 onValueChangeState 持有了 "onValueChange" 最新的引用;
也就是说:onValueChangeState 只会创建一次,但每次都能够更新最新的引用。
所以我们可以看看 rememberUpdatedstate 的源码:
kotlin
@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
- 这是一个composable ,意味着只有唯一传参 newValue 改变,才会导致此 composable执行。
- 它返回了一个 state 对象,且remember没填key,意味着它将在composable中长久存在。
- 且返回的state不可以被修改
- 它每次执行时只做了一件事:重新给state的value赋值。
源码表明我们上方的理解完全正确。
脉络拆分之:rememberUpdatedMutableState
这是一个我参照上方源码自行实现的 Composable ------
kotlin
// 定义
@Composable
fun <T> rememberUpdatedMutableState(newValue: T): MutableState<T> = remember {
mutableStateOf(newValue)
}.apply { value = newValue }
// 使用:
var lastNotifiedState by rememberUpdatedMutableState(targetState)
// 这个lambda将在preFling和postFling中调用
val notifyStateChange: State<(PageHeightState) -> Unit> = rememberUpdatedState { newState ->
if (lastNotifiedState != newState) {
dispatcher.coroutineScope.launch { onStateChange(newState) }
lastNotifiedState = newState
}
}
除了返回的对象是一个 MutableState 之外,完全没有区别。
很显然,本文场景下,我们的 页面状态 来自于外部传参 ,但也会受到手势的影响而自行改变。
为了不重复回调 onStateChange
、且不回调传进来的改变,我们需要一个标记。
- 这个标记会随着传参改变取值。
- 也会随着手势结束而更新取值
脉络拆分之:derivedStateOf
它和上文 rememberUpdatedState
类似。
但前者观察的是传参,derivedStateOf 观察的是 state 对象。
------ 它帮你建立监听,当你需要 remember ( key1 ,key2 ) { ... }
时,如果所有的 key 都是 state 对象的话,就用 derivedStateOf 。
为什么要这样做?
因为:
remember ( key1 ,key2 ) { ... }
的形式会生成新的对象- 如果 B 引用了这个对象,当这个对象重新生成时,B也需要重新更新引用。
- B 的"重新更新引用"的做法很可能是:自己也 remember 同样多的、甚至更多的key,来保证可以按需创建新的B
- 如果 B 的创建代价很大......
- 如果 B 的关联项目很多......
nestedScroll 核心部分
剩下的就是核心代码了,老规矩,直接上源码+海量的注释:
建议初次看 nestedScroll 实战的童鞋优先看我这篇文章:
kotlin
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// child 在 preXxx 中先问我们
// 我们也是我们 parent 的 child,也应规范操作
val availableV = available.y - dispatcher.dispatchPreScroll(available, source).y
// 当不平衡时------不在3种预定义状态之一时,才介入处理(*注0)
// 当是drag------也就是用户拖拽时,才介入处理
// 当height动画未在运行时,才介入处理 (*注1)
if (height.value.isNotInBalance && source == NestedScrollSource.Drag && !height.isRunning) {
// 得到当前高度 lastHeight
val lastHeight = height.value
// 得到如果完全消耗此次滚动量时的目标位置 oriTarget
val oriTargetHeight = lastHeight - availableV
// 剩余滚动量 leftOffset
val leftOffset: Float
// 实际最终滚动位置(修正后的滚动位置)
var finalTargetHeight = oriTargetHeight
// 下面都是使得高度正确消耗、优先回到平衡态的逻辑
if (lastHeight > maxHeightFloat) {
if (oriTargetHeight <= maxHeightFloat) {
finalTargetHeight = maxHeightFloat
}
} else if (lastHeight in halfHeight..maxHeightFloat) {
if (oriTargetHeight <= halfHeight) {
finalTargetHeight = halfHeight
} else if (oriTargetHeight >= maxHeightFloat) {
finalTargetHeight = maxHeightFloat
}
} else if (lastHeight in 0f..halfHeight) {
if (oriTargetHeight >= halfHeight) {
finalTargetHeight = halfHeight
} else if (oriTargetHeight < 0f) {
finalTargetHeight = 0f
}
} else {
finalTargetHeight = 0f
}
dispatcher.coroutineScope.launch {
height.snapTo(finalTargetHeight)
}
// 回到平衡态后计算出实际消费的 offset
leftOffset = lastHeight - finalTargetHeight
return Offset(0f, leftOffset)
}
return Offset.Zero.copy(y = available.y - availableV)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return if (source == NestedScrollSource.Drag) {
// child 交过来的滚动量 + 来源是拖拽,就直接消费掉
dispatcher.coroutineScope.launch {
height.snapTo(
(height.value - available.y).coerceAtLeast(0f)
)
}
available
} else {
dispatcher.dispatchPostScroll(consumed, available, source)
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
// 老规矩
val availableV = available.y - dispatcher.dispatchPreFling(available).y
// 仅非平衡态才接过 child 的速度进行处理
if (height.value.isNotInBalance) {
val pageState: PageHeightState = computePageHeightState(height.value, availableV)
// 通知状态改变
notifyStateChange.value(pageState)
val target = transStateToHeight(pageState)
val velocityLeft = height.animateTo(target, springAnim, -available.y)
.endState
.velocity
return Velocity.Zero.copy(y = available.y - velocityLeft)
}
return Velocity.Zero.copy(y = available.y - availableV)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
// 没啥好说的,计算出目标值后滚动过去即可
val availableV = available.y
val pageState: PageHeightState = computePageHeightState(height.value, availableV)
// 通知状态改变
notifyStateChange.value(pageState)
val target = transStateToHeight(pageState)
val velocityLeft = height.animateTo(target, springAnim, -availableV)
.endState
.velocity
// 最后问一下 parent ,守规矩
val parentConsumed = dispatcher.dispatchPostFling(
consumed.copy(y = consumed.y + available.y - velocityLeft),
available.copy(y = velocityLeft)
)
return Velocity.Zero.copy(y = available.y - velocityLeft) + parentConsumed
}
注0:child 滚动到尽头时,会回调 postScroll ,让我们继续消耗滚动量------
- 使得我们进行滚动
- 使得我们立即进入不平衡状态
- 下一次 preScroll 将立即接过所有拖拽导致的滚动事件进行消耗
注1:如果当前正在动画状态------
- 说明 postScroll 未接管操作
- 进一步说明 child 正在滚动
- 但本 modifier 所修饰的内容也正在动画归位ing
- 所以不应此时进行处理
全文完毕
后日谈
实现滚动条、滚动区域,可以这样实现一个子 Composable :
kotlin
// slideBar
val scrollableState = rememberScrollState()
Column(Modifier.fillMaxWidth()
.verticalScroll(scrollableState, flingBehavior = rememberNoneFlingBehavior())
.padding(vertical = 16.dp),
Arrangement
.Center, Alignment
.CenterHorizontally) {
Box(Modifier
.width(60.dp)
.height(6.dp)
.background(color = Color(0xFFD9D9D9), shape = RoundedCornerShape(size = 3.5.dp))
)
}
// 子组件本身不需要滚动,就用这个behavior
@Composable
fun rememberNoneFlingBehavior(
): FlingBehavior = remember {
object : FlingBehavior {
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
return initialVelocity
}
}
}
如果子组件需要滚动,比如是个 LazyList 。
在谷歌将bug修复前,请参照这篇文章所述,使用其中的:rememberOverscrollFlingBehavior
Compose 用 nestedScroll 实现iOS的回弹效果,还要帮谷歌修bug?
( bug 的信息和对应 issue 也在文中有具体描述)