前言:
对于android原生来说,可以根据 (CoordinatorLayout+AppBarLayout+ CollapsingToolbarLayout)与(behavior+collapseMode+scrollFlags) 来实现一个折叠效果,而对于Compose来说,官方并未提供类似的Api,因此需要自己去定制一个。
原本我也研究了一些时间,但是偶然发现github上已有人做了同样的事情,思想方面都大同小异,本着不重复做轮子的原则,因此在他原本代码的基础上修复一些bug。
这是我参考的轮子:GitHub - Tlaster/NestedScrollView: NestedScrollView for Jetpack Compose
正题:
实现折叠,首先需要几个关键性的东西(如果你是单个列表折叠,那使用stickyHeader可以轻松实现,但是如果包含了Viewpager,则需要自己去定制):
(1)NestedScrollConnection
(2)Layout测量与放置
(3)一些滑动冲突与事件消费
从效果分析开始(以最下方是ViewPager包裹RecyclerView样式进行假想),整个滑动过程分为两大步:
(1)在折叠完成之前 ,整体滑动而recyclerView不滑动 ->事件交给最外层消费掉
? (2)? ?在折叠完成之后,整体不动,而recyclerView自行滑动->事件交给recyclerView消费
有效果分析开始寻找可以帮助实现的api,原生android是使用View的事件模型进行拦截等等,而Compose使用的是NestedConnection且更为简单
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return takeIf {
available.y < 0 && source == NestedScrollSource.Drag
}?.let {
Offset(0f, drag(available.y))
} ?: Offset.Zero
}
}
以这一段代码为例,重写其onPreScroll,return了两种情况(先不管drag方法的实现),Offset(0f,available.y)表示由使用了这个NestedConnection的Composeable组件消费掉当前位移量(拦截),?而返回Offset.Zero表示,使用了这个NestedConnection的Composeable组件不进行任何操作(事件分发)
因此在这里可以完成上述的效果分析,其余的是对效果的补充以及修正等,核心思路已出
在我自己的代码中,我删除了其外层的
// .scrollable(
// orientation = orientation,
// state = rememberScrollableState {
// Log.e("tagaaa","rememberScrollableState $it")
// state.drag(it,false)
// }
// )
而是在需要滑动的地方,加上一些可滑动申明,如:
Column(
modifier = Modifier
.padding(16.dp)
.verticalScroll(
rememberScrollState()
)
) {
Text(text = "This is some awesome title")
Text(text = "This is some awesome title")
Text(text = "This is some awesome title")
}
以此达到检测滑动的唯一来源 只有NestedscrollConnection,且避免了一些奇奇怪怪的bug,同时,因为在外层添加了VerticalScroll,内部测量方式也需要进行改变
val headerPlaceable =
measurables[0].measure(constraints.copy())
去除了
Constraints.Infinity
以下为修改后的代码(并且因为可以拿到offset,所以很多其他的行为就可以在外部自由扩展):
import android.util.Log
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Constraints
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* Define a [VerticalNestedScrollView].
*
* @param state the state object to be used to observe the [VerticalNestedScrollView] state.
* @param modifier the modifier to apply to this layout.
* @param content a block which describes the header.
* @param content a block which describes the content.
*/
@Composable
fun VerticalNestedScrollView(
modifier: Modifier = Modifier,
state: NestedScrollViewState,
header: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
NestedScrollView(
modifier = modifier,
state = state,
orientation = Orientation.Vertical,
header = header,
content = content,
)
}
/**
* Define a [HorizontalNestedScrollView].
*
* @param state the state object to be used to observe the [HorizontalNestedScrollView] state.
* @param modifier the modifier to apply to this layout.
* @param content a block which describes the header.
* @param content a block which describes the content.
*/
@Composable
fun HorizontalNestedScrollView(
modifier: Modifier = Modifier,
state: NestedScrollViewState,
header: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
NestedScrollView(
modifier = modifier,
state = state,
orientation = Orientation.Horizontal,
header = header,
content = content,
)
}
@Composable
private fun NestedScrollView(
modifier: Modifier = Modifier,
state: NestedScrollViewState,
orientation: Orientation,
header: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
Layout(
modifier = modifier.nestedScroll(state.nestedScrollConnectionHolder),
content = {
Box {
header.invoke()
}
Box {
content.invoke()
}
},
) { measurables, constraints ->
layout(constraints.maxWidth, constraints.maxHeight) {
when (orientation) {
Orientation.Vertical -> {
val headerPlaceable =
measurables[0].measure(constraints.copy())
headerPlaceable.place(0, state.offset.roundToInt())
val contentPlaceable =
measurables[1].measure(constraints.copy(maxHeight = constraints.maxHeight))
contentPlaceable.place(
0,
state.offset.roundToInt() + headerPlaceable.height
)
state.updateBounds(-(headerPlaceable.height.toFloat()))
state.headerHeight = headerPlaceable.height
}
Orientation.Horizontal -> {
val headerPlaceable =
measurables[0].measure(constraints.copy(maxWidth = Constraints.Infinity))
headerPlaceable.place(state.offset.roundToInt(), 0)
state.updateBounds(-(headerPlaceable.width.toFloat()))
val contentPlaceable =
measurables[1].measure(constraints.copy(maxWidth = constraints.maxWidth))
contentPlaceable.place(
state.offset.roundToInt() + headerPlaceable.width,
0,
)
}
}
}
}
}
import androidx.annotation.FloatRange
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
import kotlin.math.withSign
/**
* Create a [NestedScrollViewState] that is remembered across compositions.
*/
@Composable
fun rememberNestedScrollViewState(): NestedScrollViewState {
val scope = rememberCoroutineScope()
val saver = remember {
NestedScrollViewState.Saver(scope = scope)
}
return rememberSaveable(
saver = saver
) {
NestedScrollViewState(scope = scope)
}
}
/**
* A state object that can be hoisted to observe scale and translate for [NestedScrollView].
*
* In most cases, this will be created via [rememberNestedScrollViewState].
*/
@Stable
class NestedScrollViewState(
private val scope: CoroutineScope,
initialOffset: Float = 0f,
initialMaxOffset: Float = 0f,
) {
var headerHeight: Int = 0
private var changes = 0f
private var _offset = Animatable(initialOffset)
private val _maxOffset = mutableStateOf(initialMaxOffset)
companion object {
fun Saver(
scope: CoroutineScope,
): Saver<NestedScrollViewState, *> = listSaver(
save = {
listOf(it.offset, it._maxOffset.value)
},
restore = {
NestedScrollViewState(
scope = scope,
initialOffset = it[0],
initialMaxOffset = it[1],
)
}
)
}
/**
* The current value for [NestedScrollView] Content translate
*/
@get:FloatRange(from = 0.0)
val offset: Float
get() = _offset.value
internal val nestedScrollConnectionHolder = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return takeIf {
available.y < 0 && source == NestedScrollSource.Drag
}?.let {
Offset(0f, drag(available.y))
} ?: Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return takeIf {
available.y > 0 && source == NestedScrollSource.Drag
}?.let {
Offset(0f, drag(available.y))
} ?: Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, fling(available.y))
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
return Velocity(0f, fling(available.y))
}
}
private suspend fun snapTo(value: Float) {
_offset.snapTo(value)
}
internal suspend fun fling(velocity: Float): Float {
if (velocity == 0f || velocity > 0 && offset == 0f) {
return velocity
}
val realVelocity = velocity.withSign(changes)
changes = 0f
return if (offset > _maxOffset.value && offset <= 0) {
_offset.animateDecay(
realVelocity,
exponentialDecay()
).endState.velocity.let {
if (offset == 0f) {
velocity
} else {
it
}
}
} else {
0f
}
}
internal fun drag(delta: Float): Float {
return if (delta < 0 && offset > _maxOffset.value || delta > 0 && offset < 0f) {
changes = delta
scope.launch {
snapTo((offset + delta).coerceIn(_maxOffset.value, 0f))
}
delta
} else {
0f
}
}
internal fun updateBounds(maxOffset: Float) {
_maxOffset.value = maxOffset
_offset.updateBounds(maxOffset, 0f)
}
}
|