水平有限,大概翻译一下,同时加点东西。
众所周知,RecyclerView 可以通过设置 LayoutManager 的方式来选择滑动的方向,RecyclerView 还实现了 NestedScrollingChild3 接口来支持嵌套滚动,NestedScroll 机制可以让内层滚动子项拦截滚动事件,并与外层嵌套父项启动嵌套滚动。CoordinatorLayout 就是比较典型的例子,内部的 Behavior 可以监听实现了 NestedScrollingChild 接口的子 View 的滑动状态(用于实现嵌套滚动),当然,它的功能不仅仅只有这个,还可以监听位置和尺寸的变化(折叠布局和动画效果),这里就不做过多讨论了。
前不久,最新推出了 ViewPager2,不过它和 ViewPager 不同的是,内部是基于 RecyclerView 实现的,这样就会导致出现一些问题,问题的起因是 RecyclerView 并未实现 NestedScrollingParent3 接口,因此不支持内部嵌套可滚动(相同方向)的子项。不过官方提供了一个解决方案,用来支持 ViewPager2 中实现嵌套滚动。
class NestedScrollableHost : FrameLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
private var touchSlop = 0
private var initialX = 0f
private var initialY = 0f
private val parentViewPager: ViewPager2?
get() {
var v: View? = parent as? View
while (v != null && v !is ViewPager2) {
v = v.parent as? View
}
return v as? ViewPager2
}
private val child: View? get() = if (childCount > 0) getChildAt(0) else null
init {
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
}
private fun canChildScroll(orientation: Int, delta: Float): Boolean {
val direction = -delta.sign.toInt()
return when (orientation) {
0 -> child?.canScrollHorizontally(direction) ?: false
1 -> child?.canScrollVertically(direction) ?: false
else -> throw IllegalArgumentException()
}
}
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
handleInterceptTouchEvent(e)
return super.onInterceptTouchEvent(e)
}
private fun handleInterceptTouchEvent(e: MotionEvent) {
val orientation = parentViewPager?.orientation ?: return
// Early return if child can't scroll in same direction as parent
if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
return
}
if (e.action == MotionEvent.ACTION_DOWN) {
initialX = e.x
initialY = e.y
parent.requestDisallowInterceptTouchEvent(true)
} else if (e.action == MotionEvent.ACTION_MOVE) {
val dx = e.x - initialX
val dy = e.y - initialY
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
// assuming ViewPager2 touch-slop is 2x touch-slop of child
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
if (scaledDx > touchSlop || scaledDy > touchSlop) {
if (isVpHorizontal == (scaledDy > scaledDx)) {
// Gesture is perpendicular, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
} else {
// Gesture is parallel, query child if movement in that direction is possible
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
// Child can scroll, disallow all parents to intercept
parent.requestDisallowInterceptTouchEvent(true)
} else {
// Child cannot scroll, allow all parents to intercept
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
}
}
}
上面的思路总结为,如果内部有可以水平方向滑动的子视图,那么在执行 onInterceptTouchEvent() 方法之前调用 requestDisallowInterceptTouchEvent() 来让外层不要拦截这个事件。这是翻译文章,不多扯事件机制了。
问题
正常的逻辑思维,嵌套反方向的滚动视图,应该是开箱即用的。垂直的滚动视图不应该拦截水平方向手势,反之亦然。不过呢,ViewPager 源码里,工程师们做了滑动冲突的处理;而 ViewPager2 只考虑了水平方向的滑动。
上面已经提了 ViewPager2 是基于 RecyclerView 实现的,那么在垂直方向的 RecyclerView 中嵌套了几个水平方向的 RecyclerView,结果可想而知。
预期是水平滑动,结果是外层 RecyclerView 拦截处理了这次事件,导致的垂直滚动。为什么会产生这种情况,因为很多时候的触摸事件,并不是完全水平的手势,更多的是对角线形式的,即使是意图非常明显的水平手势(水平滑动距离大于垂直滑动距离)。用户呀,毕竟是人,不可能每次划出完美的笔直的横线。
下面是另外一种情况,横向 ViewPager2 内部嵌套垂直滚动视图 :
不用想,外部的 ViewPager2 拦截了内部 RecylcerView 的垂直滑动事件,导致 ViewPager2 横向滑动。
原因
真男人是不会后退的,你让我不用 ViewPager2,重新用 ViewPager?抱歉!这是不可能的!
问题出在哪里呢?遇事不决,量子力学!搞错了,直蹦源码。
// RecyclerView -> onInterceptTouchEvent()
boolean canScrollHorizontally = mLayout.canScrollHorizontally();
boolean canScrollVertically = mLayout.canScrollVertically();
switch (action) {
...
case MotionEvent.ACTION_MOVE: {
...
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
if (mScrollState != SCROLL_STATE_DRAGGING) {
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
boolean startScroll = false;
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
mLastTouchX = x;
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
mLastTouchY = y;
startScroll = true;
}
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
} break;
...
}
return mScrollState == SCROLL_STATE_DRAGGING;
上面提到过 RecyclerView 是有一个确定的方向的,而且有滑动手势大多数情况为对角线方向,也就是斜的(俩个方向都有),假设现在有俩个反方向的 RecyclerView 嵌套,滑动时,只要有一小部分滑动距离(外层 RecyclerView 相同方向的)大于可滑动的最小距离,那么外层 RecyclerView 就会拦截处理这次滑动事件。
问题就在于,RecyclerView 单向滚动不会计算这个手势是否更接近不同方向的手势,再决定是否拦截它,这样就会导致嵌套滑动冲突。
原文作者提出了修改方案,对单个方向的滚动进行优化。
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop && (canScrollVertically || Math.abs(dx) > Math.abs(dy))) {
mLastTouchX = x;
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop && (canScrollHorizontally || Math.abs(dy) > Math.abs(dx))) {
mLastTouchY = y;
startScroll = true;
}
解决方案
哦,我牛逼的原文作者希望上述方案,Google 工程师能够在新版源码中实现。不过我去查看了最新的版本,还是老样子。下面来讨论一下其他的可行性方案。
显而易见的可以想到直接重写 RecyclerView的 onInterceptTouchEvent() 方法不就好了,很抱歉,里面有大量的私有字段,所以必须要调用 super.onInterceptTouchEvent() 方法,并在这个语句后,添加一些其他操作,对逻辑进行调整。ViewPager2 也是 final 类,是不可以通过继承的方式修改使用的,事实证明,这个方案是不切实际的。
可以通过实现 OnItemTouchListener 和 OnScrollListener 俩个接口的方式,来实现想要的效果:
fun RecyclerView.enforceSingleScrollDirection() {
val enforcer = SingleScrollDirectionEnforcer()
addOnItemTouchListener(enforcer)
addOnScrollListener(enforcer)
}
private class SingleScrollDirectionEnforcer : RecyclerView.OnScrollListener(), OnItemTouchListener {
private var scrollState = RecyclerView.SCROLL_STATE_IDLE
private var scrollPointerId = -1
private var initialTouchX = 0
private var initialTouchY = 0
private var dx = 0
private var dy = 0
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
when (e.actionMasked) {
MotionEvent.ACTION_DOWN -> {
scrollPointerId = e.getPointerId(0)
initialTouchX = (e.x + 0.5f).toInt()
initialTouchY = (e.y + 0.5f).toInt()
}
MotionEvent.ACTION_POINTER_DOWN -> {
val actionIndex = e.actionIndex
scrollPointerId = e.getPointerId(actionIndex)
initialTouchX = (e.getX(actionIndex) + 0.5f).toInt()
initialTouchY = (e.getY(actionIndex) + 0.5f).toInt()
}
MotionEvent.ACTION_MOVE -> {
val index = e.findPointerIndex(scrollPointerId)
if (index >= 0 && scrollState != RecyclerView.SCROLL_STATE_DRAGGING) {
val x = (e.getX(index) + 0.5f).toInt()
val y = (e.getY(index) + 0.5f).toInt()
dx = x - initialTouchX
dy = y - initialTouchY
}
}
}
return false
}
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {}
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
val oldState = scrollState
scrollState = newState
if (oldState == RecyclerView.SCROLL_STATE_IDLE && newState == RecyclerView.SCROLL_STATE_DRAGGING) {
recyclerView.layoutManager?.let { layoutManager ->
val canScrollHorizontally = layoutManager.canScrollHorizontally()
val canScrollVertically = layoutManager.canScrollVertically()
if (canScrollHorizontally != canScrollVertically) {
if ((canScrollHorizontally && abs(dy) > abs(dx))
|| (canScrollVertically && abs(dx) > abs(dy))) {
recyclerView.stopScroll()
}
}
}
}
}
}
重写 OnItemTouchListener 的 onInterceptTouchEvent 方法,逻辑不变,返回 false 即可,目的是不要拦截事件;重写 OnScrollListener 的 onScrollStateChanged 方法,在静止状态向拖动状态转变时,如果与 RecyclerView 反向的滑动距离大于同方向的距离,停止滑动。
对于 ViewPager2 来说,先获取内部的 RecyclerView,再采用上述方案即可。
val ViewPager2.recyclerView: RecyclerView
get() {
return this[0] as RecyclerView
}
val pager: ViewPager2 = findViewById(R.id.pager)
pager.recyclerView.enforceSingleScrollDirection()
到这里,问题就解决完毕了。
可以看到的是,现在的滑动行为和预期是一致的。
总结
嵌套滑动问题是不可避免的,这里提供了一个临时可解决方案。
感谢各位的阅读,欢迎交流学习。
翻译水平和自身功力有限,有什么问题敬请谅解!