Compose 嵌套滑动冲突的解决办法

前言

在最近我利用业余时间使用 Compose 写的 Gihub APP 中,它的首页结构是这样的:

1

采用了 Drawer 嵌套 Pager 的结构。

这就会出现一个问题,那就是 DrawerPager 都需要监听横向滑动手势,从而实现展开 Drawer 和 切换 Pager 的功能。

那么,如果我把他们嵌套在一起使用会发生什么呢?谁能最终拿到手势事件呢?

而在我这个 APP 中其中一个 Pager 页面中又额外嵌套了一个 webview 页面,这个页面也需要获取到横向滑动手势,如果此时我切换到这个页面又会发生什么呢?

实际发生的和我们希望的

在上述的场景中,实际会发生的情况是,如果我们手势滑动的位置是在中间的内容区域的话,触发的会是 Pager 的手势,从而发生页面的切换。

如果我们的手势滑动的区域是在中间内容区域之外,例如顶部菜单栏的话触发的就是 Drawer 的手势,从而发生展开 Drawer 的事件。

至于这个 webview ,显然,无论如何它都不会被触发。

那么,我们期望的处理流程是什么样的呢?

显然,我们期望的是无论滑动哪个地方的手势都应该优先触发 Pager 然后在到达第一页的时候向右滑动改为触发 Drawer 而向左滑动则依旧触发 Pager ,至于 webview 我们期望的是无论它是在哪个页面,只要我们的手指触摸到的是它的内容范围,则应该优先触发它的手势。

那么,怎么才能实现我们的目的呢?

其实在 Compose 1.2.0 版本中就已经提供了官方的嵌套滚动互操作的支持:Nested scrolling interop

仔细阅读这篇指南就会发现,虽然 Compose 官方提供了嵌套滚动互操作的 API 支持,大多数情况下只需要添加一个 Modifier.nestedScroll(nestedScrollInterop) 即可实现我们上述所说的需求。

然而,并非所有的组件都支持上述这个 API:

This issue is a result of the expectations built in scrollable composables. Scrollable composables have a “nested-scroll-by-default” rule, which means that any scrollable container must participate in the nested scroll chain, both as a parent via NestedScrollConnection, and as a child via NestedScrollDispatcher. The child would then drive a nested scroll for the parent when the child is at the bound. As an example, this rule allows Compose Pager and Compose LazyRow to work well together. However, when interoperability scrolling is being done with ViewPager2 or RecyclerView, since these don’t implement NestedScrollingParent3, the continuous scrolling from child to parent is not possible.

不幸的是,我这里所需要实现嵌套滚动的恰好是不受支持的组件。

所以,我们只能自己去实现了。

解决 Drawer 和 Pager 的滑动冲突

那么,我们就先从简单的开始,先去解决同为 Compose 组件的 Drawer 和 Pager 的冲突。

在开始之前,我们先明确一下我们需要解决的问题,那就是我们需要实现如果在第一页时,如果向右滑则触发 Drawer 展开侧栏;向左滑则触发 Pager 切换至下一页。如果不在第一页那么就只需要触发 Pager 切换页面。

为了解决这个问题,我首先想到的方法是如同原生 View 那样的,通过拦截触摸事件,然后在按照我们需求去重新分配触摸事件。

但是在我实际尝试过程中发现在 Compose 中想要拦截并重新分配触摸事件似乎不是那么的好实现。

所以我这里使用了一种折中的方法来实现。

由于相对于 Drawer 来说 Pager 是其子界面,所以这里我们选择给 Pager 添加一个 Modifier.draggable() 修饰,使用这个修饰我们可以获取到在 Pager 的单一方向的滑动手势,以及其滑动距离等数据:

HorizontalPager(
    // ……
    state = pagerState,
    modifier = Modifier.draggable(state = rememberDraggableState {offset ->
         // ……
         // 这里的 offset 即获取到的手势滑动的变化值
    },
        orientation = Orientation.Horizontal, // 这里表示只获取水平方向上的手势
        enabled = pagerState.currentPage == 0)
) { page ->
    // ……
}

在这里按照我们的需求,我们也给这个 Modifier.draggable 修饰加上了启用条件 enabled = pagerState.currentPage == 0 即只有当前处于第一页时才启用这个获取手势的修饰。

接下来,我们按照需求,判断手势的变化值来确定是需要展开 Drawer 还是切换 Pager ,其实判断方法也很简单,如果值为正则说明是向右滑动,则应该展开 Drawer ;如果值为负则说明是向左滑动,应该要切换 Pager 的页面:

HorizontalPager(
    // ……
    state = pagerState,
    modifier = Modifier.draggable(state = rememberDraggableState {offset ->
        if (drawerState.isClosed && !drawerState.isAnimationRunning) {
            if (offset >= 5f) {
            		// ……
            		// 在这里触发展开 Drawer
            }
            else if (offset < -5f && pagerState.canScrollForward && !pagerState.isScrollInProgress){
            		// ……
            		// 在这里触发切换页面
            }
        }
    },
        orientation = Orientation.Horizontal,
        enabled = pagerState.currentPage == 0)
) { page ->
    // ……
}

在这里我们为了避免误触,把判断的阈值设置为了 ± 5 个单位。

另外,为了避免在已经开始展开 Drawer 或切换页面时重复触发,我们首先要确保当前 Drawer 处于关闭状态,且没有处于状态变化中:

if (drawerState.isClosed && !drawerState.isAnimationRunning)

同理,当触发切换页面时也需要保证当前没有在切换过程中,且应当处于可以切换的状态:

if (offset < -5f && pagerState.canScrollForward && !pagerState.isScrollInProgress)

在这里我们分别用 drawerState.open()pagerState.animateScrollToPage(1) 来触发展开 Drawer 和切换页面。

其实这里如果想做的更“友好”一点或者说更“跟手”一点,那么我们应该是根据当前的手势滑动的值实时更新相应的布局变化值,直到达到某个阈值才认为状态变更完成,否则“弹回”未变更前。而不是像现在这样,不管滑了多少距离,直接二话不说就直接触发状态完全变化。

但是目前 DrawerPager 都没有提供相应的 API,所以只能这么粗暴的去实现了。

Draer 虽然有一个 drawerState.offset 参数,但是它是只读的)

至此,虽然不太完美,但是也实现了我们的需求,效果如下:

2

解决 Pager 和 Webview 的冲突

上一节我们讲了同为 Compose 组件之间的嵌套滚动冲突的解决方法,接下来我们讲一讲 Compose 嵌套原生 View 的滑动冲突解决方法。

但是正如我在前言中所说, Compose 并不能很好的拦截并重新分配触摸事件。但是 VIew 是可以很容易的做到这一点的,因此我们的想法很简单,就是在 Webview 中拦截掉所有的触摸事件即可。

只是在这个 APP 中,webview 是被嵌套在多个有可能拦截触摸事件的 Compose 组件中的: webview -> LazyColumn -> SwipeRefresh -> Pager -> Drawer 。

好在,这几个组件都提供了禁用拦截触摸事件的参数:

LazyColumn 中有一个 userScrollEnabled 参数,当将这个参数设置为 false 时,LazyColumn 就不会再拦截触摸事件。

SwipeRefresh 中有一个 swipeEnabled 参数,当将这个参数设置为 false 时,SwipeRefresh 就不会再拦截触摸事件。

HorizontalPager 中有一个名为 userScrollEnabled 的参数,当将这个参数设置为 false 时,Pager 就不会再拦截触摸事件。

ModalNavigationDrawer 的中有一个叫 gesturesEnabled 的参数,将其设置为 false 时也不会再拦截触摸事件。

所以这里我们就从这几个参数下手,首先为 webview 设置触摸监听,如果 webview 接收到了触摸事件就回调给上述几个函数,设置其对应的参数为 false,确保其不会拦截 webview 的触摸事件,当 webview 失去触摸事件时就将其设置会 true,让他们继续相应对应组件的触摸事件。

为了实现这个目的,我们首先给封装的 webview 组件提供一个 onTouchEvent 回调:

@Composable
fun CustomWebView(
    // ……
    onTouchEvent: ((event: MotionEvent) -> Boolean)? = null
) {
    AndroidView(
        factory = { ctx ->
            WebView(ctx).apply {
                // ……

                if (onTouchEvent != null) {
                    setOnTouchListener { v, event ->
                        onTouchEvent(event)
                    }
                }
            }
        }
    )
}

在上述代码中,我们把触摸事件回调给了 onTouchEvent 函数。

因此我们在实际调用 CustomWebView 这样写:

CustomWebView(
    // ……
    onTouchEvent = {
        when (it.action) {
            MotionEvent.ACTION_DOWN -> {
                changeaScrollState(false)
                false
            }
            MotionEvent.ACTION_UP -> {
                changeaScrollState(true)
                false
            }
            else -> {
                false
            }
        }
    }
)

上述代码中,我们通过 changeaScrollState 函数设置我们一开始提到的几个 Compose 组件的触摸事件启用状态。

这里有一个地方需要注意,在 onTouchEvent 中记得一定要返回 false 表示只是获取这个触摸事件但是不消费,否则虽然触摸事件不会被其他 Compose 拦截消费了,但是却被我们自己消费了,webview 依然是接受不到这个触摸事件的。

最终实现效果如下:

3

可以看到,现在 Webview 已经能够自由的滚动而不会受到几个父布局的影响了,并且当我们滑动的是 webview 以外的区域时,其他组件依旧能够正常滚动。

总结

以上就是我目前遇到的在 Compose 中的手势冲突的情况,以及我的解决方案。

完整的代码在这里: GithubAppByCompose

可以看到其实核心思路也是和使用 view 时一样,根据我们自己实际业务需求,重新分配不同的触摸事件给不同的 UI 。

不过我的处理方式实在无法称作优雅,所以各位大佬如果有更优雅的处理方式,希望能不吝赐教。