不管是View 还是Compose 列表的滚动不停止,是不能进行水平滑动的,要实现滚动状态下允许左右滑,只能自己处理下他们默认的事件分发。
列表上下滑动过程中允许水平Pager左右滑,之前用View 实现过,核心代码其实就是那么几行,重写RecyclerView#onInterceptTouchEvent方法。
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
val action = e.actionMasked
if (action == MotionEvent.ACTION_DOWN && scrollState == SCROLL_STATE_SETTLING) {
parent.requestDisallowInterceptTouchEvent(false)
stopScroll()
return scrollState == SCROLL_STATE_DRAGGING
}
return super.onInterceptTouchEvent(e)
}
虽然代码量不多,但是对事件分发机制没一定理解的话,也没那么容易看得懂。
大致原理就是在ACTION_DOWN 并且 RV在自动滚动状态时设置parent.requestDisallowInterceptTouchEvent(false) ,让ViewPager 重新获得事件的控制权,之后的事件序列就会进入ViewPager 的 onInterceptTouchEvent方法,当符合xDiff > mTouchSlop && xDiff * 0.5f > yDiff 时,就会拦截RV事件,自己去处理 。
那么Compose 怎么实现LazyColumn上下滑动过程中允许HorizontalPager左右滑,这样类似的效果?
我们先来看下Compose 的事件分发机制。
Compose 事件分发机制
先来看一段代码
运行这段代码输出如下,
可以发现,Compose 事件传递是有三种类型的 Initial, Main, Final 。 Initial 和Final 是从前往后的,类似VIewGroup的dispatchTouchEvent,onInterceptTouchEvent,都是父View 先处理的。Main 类似onTouchEvent 最后面的优先处理。
每一个事件序列都是由Press ->... Move ... -> Release 和 View 一样。
LazyColumn 滑动原理
通过源码我们发现lazycolumn 和 HorizontalPager都是LazyLayout,他们的滑动其实是靠scrollable 来实现的
LazyLayout(
modifier = modifier
.scrollable(
orientation = orientation,
reverseDirection = ScrollableDefaults.reverseDirection(
LocalLayoutDirection.current,
orientation,
reverseLayout
),
interactionSource = state.internalInteractionSource,
flingBehavior = flingBehavior,
state = state,
overscrollEffect = overscrollEffect,
enabled = userScrollEnabled
)
)
继续跟进scrollable,里面有个AbstractDraggableNode,里面有个pointerInputNode , 所有事件都是通过这个分发的,这个的作用就类似于view的dispatchTouchEvent
val pointerInputNode = delegate(SuspendingPointerInputModifierNode {
// TODO: conditionally undelegate when aosp/2462416 lands?
if (!enabled) return@SuspendingPointerInputModifierNode
coroutineScope {
try {
awaitPointerEventScope {
while (isActive) {
awaitDownAndSlop(
_canDrag,
_startDragImmediately,
velocityTracker,
pointerDirectionConfig
)?.let {
/**
* The gesture crossed the touch slop, events are now relevant
* and should be propagated
*/
if (!isListeningForEvents) {
startListeningForEvents()
}
var isDragSuccessful = false
try {
isDragSuccessful = awaitDrag(
it.first,
it.second,
velocityTracker,
channel,
reverseDirection
) { event ->
pointerDirectionConfig.calculateDeltaChange(
event.positionChangeIgnoreConsumed()
) != 0f
}
} catch (cancellation: CancellationException) {
isDragSuccessful = false
if (!isActive) throw cancellation
} finally {
val maximumVelocity = currentValueOf(LocalViewConfiguration)
.maximumFlingVelocity.toFloat()
val event = if (isDragSuccessful) {
val velocity = velocityTracker.calculateVelocity(
Velocity(maximumVelocity, maximumVelocity)
)
velocityTracker.resetTracking()
DragStopped(velocity * if (reverseDirection) -1f else 1f)
} else {
DragCancelled
}
channel.trySend(event)
}
}
}
}
} catch (exception: CancellationException) {
if (!isActive) {
throw exception
}
}
}
})
看到了awaitPointerEventScope,是不是很熟悉的感觉了,所有事件都是在这个协程域处理的。我们看下awaitDownAndSlop
private suspend fun AwaitPointerEventScope.awaitDownAndSlop(
canDrag: (PointerInputChange) -> Boolean,
startDragImmediately: () -> Boolean,
velocityTracker: VelocityTracker,
pointerDirectionConfig: PointerDirectionConfig
): Pair<PointerInputChange, Offset>? {
val initialDown =
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
return if (!canDrag(initialDown)) {
null
} else if (startDragImmediately()) {
initialDown.consume()
velocityTracker.addPointerInputChange(initialDown)
// since we start immediately we don't wait for slop and the initial delta is 0
initialDown to Offset.Zero
} else {
val down = awaitFirstDown(requireUnconsumed = false)
velocityTracker.addPointerInputChange(down)
var initialDelta = Offset.Zero
val postPointerSlop = { event: PointerInputChange, offset: Offset ->
velocityTracker.addPointerInputChange(event)
event.consume()
initialDelta = offset
}
val afterSlopResult = awaitPointerSlopOrCancellation(
down.id,
down.type,
pointerDirectionConfig = pointerDirectionConfig,
onPointerSlopReached = postPointerSlop
)
if (afterSlopResult != null) afterSlopResult to initialDelta else null
}
}
嘿嘿,有个startDragImmediately , 这个方法在自动滚动会返回true,类似于RecycleVIew 的SCROLL_STATE_SETTLING 状态, 并且down事件时会消费然后返回。
假设此时LazyColumn 在上下滚动中,LazyColumn 的startDragImmediately返回true。HorizontalPager 会进入awaitPointerSlopOrCancellation,等待有效滚动。
我们再来看看awaitDrag
private suspend fun AwaitPointerEventScope.awaitDrag(
startEvent: PointerInputChange,
initialDelta: Offset,
velocityTracker: VelocityTracker,
channel: SendChannel<DragEvent>,
reverseDirection: Boolean,
hasDragged: (PointerInputChange) -> Boolean,
): Boolean {
val overSlopOffset = initialDelta
val xSign = sign(startEvent.position.x)
val ySign = sign(startEvent.position.y)
val adjustedStart = startEvent.position -
Offset(overSlopOffset.x * xSign, overSlopOffset.y * ySign)
channel.trySend(DragStarted(adjustedStart))
channel.trySend(DragDelta(if (reverseDirection) initialDelta * -1f else initialDelta))
return onDragOrUp(hasDragged, startEvent.id) { event ->
// Velocity tracker takes all events, even UP
velocityTracker.addPointerInputChange(event)
// Dispatch only MOVE events
if (!event.changedToUpIgnoreConsumed()) {
val delta = event.positionChange()
event.consume()
channel.trySend(DragDelta(if (reverseDirection) delta * -1f else delta))
}
}
}
hasDragged
pointerDirectionConfig.calculateDeltaChange(
event.positionChangeIgnoreConsumed()
) != 0f
这个方法只要在垂直不为0返回true,手指在屏幕上触摸,垂直方向一定不为0,所以hasDragged一定返回true,所以此时LazyColumn 是一定会消费所有的事件,导致HorizontalPager 的事件被拦截了。
所以看了源码要实现这个效果就很简单了,当x >2*y 我们禁用LazyColumn的事件,并把后续的事件交给HorizontalPager去处理,HorizontalPager就能左右滑动了。
代码实现
在LazyColumn 的 modifier.pointerInput中处理就行了
.pointerInput(Unit) {
awaitEachGesture {
userScrollEnabled = true
awaitPointerEvent(PointerEventPass.Final)
awaitPointerEvent(PointerEventPass.Main).also {
val pos = it.changes
.first()
.positionChangeIgnoreConsumed()
if (pos.x.absoluteValue > 2 * pos.y.absoluteValue) {
userScrollEnabled = false
scope.launch {
lazyListState.stopScroll()
}
it.changes.first().consumed.positionChange = false
it.changes.first().consumed.downChange = false
}
}
}
}
测试手机是几年前的 OPPO R17 ,只有60hz 刷新率,肉眼可以看到Compose在低端机上性能表现并不差,很丝滑。