Compose 事件分发机制详解&实现LazyColumn上下滑动过程中允许HorizontalPager左右滑

1,328 阅读3分钟

不管是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在低端机上性能表现并不差,很丝滑。