无侵入式的解决 ViewPager2 跟横向滑动子 View 手势冲突的一种思路
前言
在最近的开发中,我恰好遇到一个典型又棘手的问题 —— ViewPager2 与横向滚动 View(如 HorizontalScrollView 或 ReactHorizontalScrollView)之间的手势冲突
网上关于 ViewPager2 滑动冲突的文章一搜一大把,但不少内容已过时,且大同小异,真正符合实际需求的少之又少。
因此,这里我把自己的解决思路与实践过程做个记录,希望能为有类似困扰的大佬们提供点参考。
ViewPager2 滑动冲突
页面结构
先来看一下页面的基本结构:
- 最外层是一个 Activity,其中包含了 ViewPager2、底部的 Tab 和顶部的 TabLayout。
- ViewPager2 中承载着多个 Fragment,其中第一个是原生页面,其余则是加载的 RN(React Native)页面。
- 每个 Fragment 中都有可能存在横向可滑的组件,例如 HorizontalScrollView 或ReactHorizontalScrollView。
手势冲突的表现
在未做任何手势处理的情况下,主要问题表现如下:
- 正常情况:当手指在页面的非横向滑动区域左右滑动(无论是 Native 页还是 RN 页),事件会正确地传递给 ViewPager2,切换页面一切正常 —— 符合预期。
- Native 页问题:当滑动落点在
HorizontalScrollView
上时,手势响应行为就变得不可预期。可能会触发 ViewPager2 的滑动,也可能响应HorizontalScrollView
,具体取决于手指按压的时长和滑动速度,这种不确定性就不是我所预期的行为。 - RN 页问题:如果落点在
ReactHorizontalScrollView
上,则一定是响应自己的滑动事件,ViewPager2
的滑动彻底被拦截。在手势优先级上这个是符合我的预期的,但当ReactHorizontalScrollView
内容较少、无法实际左右横向滚动时,滑动操作会没有反应,给人一种页面卡顿或者滑动失效的感觉。
我的预期
我希望手势响应遵循以下逻辑:
-
如果 子 view 是可横向滑动的 view,优先响应子 View 的滑动事件
-
如果子 View 无法左右滑动,再将事件交由 ViewPager2 处理,去响应 ViewPager2 的滑动事件,避免给用户一种假死的错觉
问题梳理以及解决思路
问题梳理
结合上文的现象与项目背景,当前我们面临的关键问题可以归纳为:
1. 滑动横向子 View 时,事件消费行为不确定,有可能是 ViewPager2 响应,有可能是子 view 响应。
2. 当子 View 本身无法横向滚动时,希望将滑动事件交由 ViewPager2 处理,避免“假死”状态。
3. RN 侧的代码不可修改,问题需在 Native 层解决,因此需要一种不修改其它代码的通用的方式来处理所有横向滚动 View 的冲突问题。
解决方案
这一方案其实经历了多次迭代优化,期间尝试过多种方式,但或多或少都存在边界问题。以下是最终实现,已在多种场景下验证,效果稳定,满足预期。
先来一个个的分析下上面问题的核心原因:
滑动横向子 View 时,事件消费行为不确定,有可能是 ViewPager2 响应,有可能是子 view 响应
原因:
当手指触摸到HorizontalScrollView
的那一刻,根据手指按下的时长和滑动速度,会决定ViewPager2
和 HorizontalScrollView
到底谁来响应手势。
如果手指按下停顿一下,HorizontalScrollView
大概率能收到 ACTION_DOWN
事件,然后后续的 ACTION_MOVE
事件也是由HorizontalScrollView
来响应。
但是如果手指按下后立刻就滑动,此时,HorizontalScrollView
就大概率收不到ACTION_DOWN
事件了,被 ViewPager2
给拦截并消费掉了,此时,滑动事件也是由 ViewPager2
响应的。
这就导致了滑动时可能是宫格内部的滑动,也可能会触发 ViewPaiger2
的切换。
解决:
核心要解决的就是当手指触摸的是可横向滚动的子 view 时,要确保子 view 来消费事件,而不是ViewPager2
。
但是由于 ViewPager2
是相对上层的父 view,因此,事件一定是先到 ViewPager2
的,如果 ViewPager2
没有消费事件,子 view 才有机会响应事件。
查看源码发现 ViewPage2
本质上就是个 RecyclerView
,那么可以在初始化 ViewPager2
时,给 内部的 RecyclerView
加个addOnItemTouchListener
,在 ACTION_DOWN
的时候去找一下当前手指是否是触摸在可横向滚动的 View 上,如果是,则让 子 view 获取焦点,并且通过调用 requestDisallowInterceptTouchEvent
让 ViewPager2
不要拦截事件,确保子 View 能响应 ACTION_DOWN
示例代码:
fun handleTouchEvent(viewPager2: ViewPager2) {this.viewPager = viewPager2recyclerView = viewPager2.getChildAt(0) as? RecyclerView ?: returnrecyclerView?.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() {override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {when (e.action) {MotionEvent.ACTION_DOWN -> {runCatching {val targetView = findHorizontalScrollView(rv, e)if (targetView != null) {targetView.requestFocus()targetView.parent?.requestDisallowInterceptTouchEvent(true)}}}}return super.onInterceptTouchEvent(rv, e)}})}private fun findHorizontalScrollView(root: ViewGroup, event: MotionEvent): View? {val location = IntArray(2)root.getLocationOnScreen(location)val x = event.rawX.toInt() - location[0]val y = event.rawY.toInt() - location[1]return root.findViewTraversal { view ->if (view is HorizontalScrollView || (view is RecyclerView && view.layoutManager?.canScrollHorizontally() == true)) {val rect = Rect()//获取 view 的可见区域view.getGlobalVisibleRect(rect)rect.contains(x, y)} else {false}}}
这样一来,就能确保手指如果触摸的是可横向滚动的子 view 时,一定是子 View 消费事件。
当子 View 本身无法横向滚动时,出现不响应滑动手势的情况
原因
当子 view 消费手势事件时,如果子 view 本身内部内容不足时,是不会有滑动效果的,而且由于事件已经被消费掉了,所以 ViewPager2 也是不会响应手势的。此时就出现了假死的错觉。
解决
这个问题也比较好解决,当我们发现触摸的是可滚动 view时,给 view 添加setOnTouchListener
,并监听事件,在 ACTION_MOVE
中判断是否能左右滑动,如果无法左右滑动,就告诉父控件去拦截手势即可,测试就会走 ViewPager2
的滑动事件了。
示例代码:
@SuppressLint("ClickableViewAccessibility")private fun needChildScroll(view: View) {view.requestFocus()// 判断当前视图的滑动能力val canScrollLeft = view.canScrollHorizontally(-1) // 能否向左滑动val canScrollRight = view.canScrollHorizontally(1) // 能否向右滑动view.setOnTouchListener { v, event ->when (event.action) {MotionEvent.ACTION_MOVE -> {if (canScrollLeft || canScrollRight) {v.parent?.requestDisallowInterceptTouchEvent(true)} else {v.parent?.requestDisallowInterceptTouchEvent(false)}}MotionEvent.ACTION_UP -> {v.parent.requestDisallowInterceptTouchEvent(false)}}false}if (canScrollLeft || canScrollRight) {//告诉父控件不要拦截事件view.parent?.requestDisallowInterceptTouchEvent(true)} else {//告诉父控件可以拦截事件view.parent?.requestDisallowInterceptTouchEvent(false)}}
这样一来,就达到了不更改其它代码,只需要在初始化 ViewPager2 时调用一下handleTouchEvent
即可统一的处理滑动冲突的问题了。
示例代码:
ViewPager2ConflictManager.handleTouchEvent(binding.viewPager)
好了,本篇文章就是这样,希望能帮到你。当然如果有更好的方式,还请指教。
感谢阅读,如果对你有帮助请三连(点赞、收藏、加关注)支持。有任何疑问或建议,欢迎在评论区留言讨论。如需转载,请注明出处:喻志强的博客