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

前言

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

1.png

采用了 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.gif

解决 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.gif

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

总结

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

完整的代码在这里: GithubAppByCompose

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

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

<think>好的,用户想了解Android Compose中的脚手架侧滑抽屉。首先,我需要回忆一下Compose中相关的组件。Jetpack Compose的Scaffold组件确实包含Drawer的功能,所以可能需要从Scaffold的结构讲起。 用户可能已经知道Scaffold是Compose中用来搭建Material Design布局的脚手架,但侧滑抽屉的具体实现可能需要详细说明。需要确认Scaffold的参数,比如drawerContent和drawerState。记得Scaffold还处理了抽屉的打开和关闭状态。 然后,应该解释DrawerState,包括如何创建、控制,比如rememberDrawerState和通过CoroutineScope来打开关闭。可能用户会关心如何触发抽屉的开关,比如通过按钮点击,所以需要示例代码中的IconButton部分。 接下来,drawerContent参数需要传入一个Composable函数,这里应该给出一个简单的例子,比如显示几个导航选项。可能还需要提到手势操作,比如从屏幕边缘滑动来打开抽屉,但需要确认Compose是否默认支持,或者是否需要额外设置。 另外,Modifier的处理也可能重要,比如在抽屉内容中设置填充以避免遮挡状态栏。需要提到Modifier.padding(top = 48.dp)这样的用法,但需要确认是否正确。 还要考虑不同抽屉类型,比如模态抽屉和标准抽屉,但可能在Compose中通过Scaffold的参数来区分。需要检查文档,确认是否有gesturesEnabled这样的参数来控制是否允许手势交互。 最后,记得提醒用户实际开发中的注意事项,比如抽屉状态的托管,避免在抽屉内容中直接修改状态,以及手势冲突的处理。可能还需要提到与导航库的结合使用,比如Jetpack Navigation,但可能属于进阶内容,视情况而定。 需要确保所有代码示例正确,使用LaTeX格式的行内公式,比如变量名可能不需要,但如果有数学相关的内容,比如动画插值,可能会用到。不过在这个问题中可能不涉及,所以主要关注代码块的结构和语法正确性。 总结步骤应该是:介绍Scaffold组件,详细说明drawerContent和drawerState的使用,给出示例代码,解释状态管理和触发方法,最后补充注意事项和扩展功能。这样结构清晰,用户能够逐步理解如何实现侧滑抽屉。</think>在 Android Jetpack Compose 中实现侧滑抽屉,推荐使用 Scaffold 脚手架组件结合 Material Design 3 的导航抽屉规范。以下是实现侧滑抽屉的完整指南: $$ Scaffold( drawerContent = { /* 抽屉内容 */ }, content = { /* 主界面内容 */ } ) $$ 一、核心组件说明 1. **Scaffold 脚手架** - 提供标准 Material Design 布局结构 - 自动处理抽屉与界面其他元素的层级关系 - 内置手势交互支持(边缘滑动触发) 2. **DrawerState 状态控制** ```kotlin val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() // 打开抽屉 scope.launch { drawerState.open() } // 关闭抽屉 scope.launch { drawerState.close() } ``` 二、完整实现示例 ```kotlin @Composable fun DrawerSample() { val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() ModalNavigationDrawer( drawerState = drawerState, drawerContent = { ModalDrawerSheet( modifier = Modifier.fillMaxWidth(0.8f) ) { Column(Modifier.padding(16.dp)) { Text("导航菜单", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(16.dp)) NavigationDrawerItem( label = { Text("首页") }, selected = false, onClick = { /* 处理点击 */ } ) NavigationDrawerItem( label = { Text("设置") }, selected = false, onClick = { /* 处理点击 */ } ) } } } ) { Scaffold( topBar = { TopAppBar( title = { Text("我的应用") }, navigationIcon = { IconButton( onClick = { scope.launch { drawerState.open() } } ) { Icon(Icons.Default.Menu, "菜单") } } ) } ) { padding -> // 主内容区域 Box( modifier = Modifier .fillMaxSize() .padding(padding), contentAlignment = Center ) { Text("主界面内容") } } } } ``` 三、关键特性说明 1. **手势交互** - 支持从左边缘向右滑动打开 - 支持向右滑动关闭(当抽屉打开时) - 可通过 `gesturesEnabled` 参数禁用 2. **动画效果** - 默认包含平滑的平移动画 - 支持自定义动画参数: $$ animationSpec = tween(durationMillis = 250) $$ 3. **样式定制** - 修改抽屉宽度: ```kotlin Modifier.fillMaxWidth(0.8f) // 80% 屏幕宽度 ``` - 添加顶部状态栏间距: ```kotlin Modifier.statusBarsPadding() ``` - 更改背景色: ```kotlin drawerContainerColor = Color.White ``` 四、最佳实践建议 1. **状态管理** - 使用单向数据流模式 - 通过 ViewModel 管理导航状态 - 避免在抽屉内容中直接包含业务逻辑 2. **性能优化** - 使用 `derivedStateOf` 处理复杂状态 - 对抽屉内容使用 `LazyColumn` 实现高效列表 - 通过 `remember` 缓存耗时计算 3. **交互增强** - 添加点击外部区域关闭功能: ```kotlin if (drawerState.isOpen) { Box( Modifier .fillMaxSize() .clickable { scope.launch { drawerState.close() } } .background(Color.Black.copy(alpha = 0.3f)) ) } ``` 五、常见问题处理 1. **手势冲突** - 使用 `Modifier.pointerInput` 自定义手势优先级 - 在滚动容器中设置 `nestedScroll` 连接 2. **状态同步** - 监听导航事件更新抽屉状态: ```kotlin LaunchedEffect(key1 = currentRoute) { drawerState.close() } ``` 3. **多层级导航** - 结合 Navigation 组件实现嵌套抽屉 - 使用 `RotateTransition` 实现箭头动画指示器 建议在实际开发中根据 Material Design 3 的最新规范调整具体样式参数,并通过 `LocalConfiguration.current` 处理不同屏幕尺寸的适配需求。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值