前言
本文将会解说什么
-
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永无止息
咱们看看其 类声明:
public class CoordinatorLayout
extends ViewGroup
implements NestedScrollingParent2,
NestedScrollingParent3 { ... }
看到了吧,压根不支持你作为 嵌套子项 ( nestedChild )。
不支持的东西你强行嵌套上去,你说:bug是不是能够预期的“永无止息”。
由于 nestedScroll 相关操作必须用匹配的 nestedScroll 系列接口去完成,只经过 scroll 等接口 + 定制手势 去测验兼容的话,往往会是耗费很多精力和代价后,bug 仍是修不完。
若完成、秃头于我何异
CoordinatorLayout 的 child 需求 behavior 才能联动起来。
—— 用作例子的 behavior 当然是本文将完成的 BottomSheeBehavior
。
戋戋 2275 行罢了。
我信任各位一定有强大的精力、意志、耐力、意志力,去消化它、吸收它!
我没有。
反正我是看都懒得看。
若修正、传参承继皆孽
假设咱们要修正一个默许行为 ——
/**
* 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 naive !
咱们在 CoordinatorLayout 中能够找到 behavior的绑定流程:
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
这个LayoutParams是内部类,再跟进去:
LayoutParams(@NonNull Context context, @Nullable AttributeSet attrs) {
// 省掉无关内容...
if (mBehaviorResolved) {
mBehavior = parseBehavior(context, attrs, a.getString(
R.styleable.CoordinatorLayout_Layout_layout_behavior));
}
// ...
}
这个parseBehavior命名太标准了,一看就知道它要干啥是吧,我跟:
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 为什么是神?
不讲废话,直接讲完成思路。
成神之道,就在其中
根本数据结构和声明
数据结构
// 三种状况,很好了解吧
enum class PageHeightState {
Full, Half, Hide
}
中心声明
/**
* 嵌套翻滚底栏
* @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块区域的掩盖规模。
全体头绪
根据上面收拾,咱们又能够进一步填充咱们的代码了——
// 稍后解说这个
@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 完成此作用
- 则布局能够这么写:
@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
首要这个在啥时候用?
咱们当然能够看官方怎么做的:
// 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 的源码:
@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 ——
// 界说
@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 实战的童鞋优先看我这篇文章:
Jetpack Compose 完成iOS的回弹作用到底有多简略?跟着我,不难!(中)
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 :
// slideBar
Column(Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState(), flingBehavior = rememberNoneFlingBehavior())
.padding(vertical = 16.dp),
Arrangement.Center,
Alignment.CenterHorizontally
) {
Box(Modifier
.width(60.dp)
.height(6.dp)
.background(color = Color.Gray, shape = CircleShape)
)
}
// 子组件本身不需求翻滚,就用这个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 也在文中有详细描绘)