RecyclerView 在长按唤起 Dialog 后还能滑动的问题

#问题记录# RecyclerView 在长按唤起 Dialog 后还能滑动的问题#

问题描述#

在写负反馈需求时,有一个场景是需要长按瀑布流 Feed 列表中的卡片来弹出一个负反馈的 Dialog。在卡片的 View 的 setOnLongClickListener 方法中写好弹出 Dialog 的逻辑后,在自测的过程中我发现了,假如长按卡片,在弹出 Dialog 后,不松手,继续上下滑动,Dialog 背后的列表还是会继续跟着手势上下滑动。

分析思路#

如果了解 Android 触摸事件的分发机制的话,这个现象比较好解释,就是 Android 对长按事件的处理是在 View 收到 ACTION_DOWN 一段事件后,检查是否有长按回调(mOnLongClickListener != null)和是否可长按(isLongClickable),手指头并没有长距离滑动过,就会调用 mOnLongClickListener。这个时候事实上这个整个触摸事件的分发并没有结束,因为 View 既没有收到 ACTION_UP 也没有收到 ACTION_CANCEL 事件。所以在长按事件触发后,原来消费了 ACTION_DOWN 事件的 view 还会继续收到后续的 ACTION_MOVE 等事件并作出反应,这就是 Dialog 弹出后,后面的列表还能滑动的原因。

设想方案#

基于上述猜想的问题的原因,我对解决问题有如下几个方案

  1. 基于之前类似业务的处理的方案,我可以在长按事件触发后,即成功弹出 Dialog 后,将后面的 RecyclerView 设置成不可滚动,并在 Dialog 消失时将 RecyclerView 重新设置成可滚动
  2. 想办法在长按事件成功触发的时候让这一次的 View 的事件分发结束掉

方案分析#

方案一几乎可以肯定能解决这个问题,但并不是一个最好的解法,原因如下

  1. Dialog 弹出的时机好处理,但消失的时机其实不是很好处理,因为 Android 并没有给出 Dialog 消失的稳定确切的回调,这样我需要去考虑所有消失的情况,而如果有遗漏,或者后面别人来维护代码时不知细节,更改了东西,就会导致 Dialog 消失后 RecyclerView 不可滑动。按我的经验,假如解决一个问题,需要列举出的许多个场景作堵洞似的操作,往往会出问题,而且会给维护者带来问题。
  2. 如果需要在 ViewHolder 里面的 View 的长按回调中调用 RecyclerView 的一个方法,那必须将 RecyclerView 传进去,或者把这个方法抛出来,先不说这样透传的繁琐,而且这也违反了我之前长期践行的外部控件尽量不要对内部控件有感知,内部控件尽量不要持有外部控件的原则。
  3. 这个方案是明显的治标的方案,它只是去解决了 RecyclerView 会继续滑动这个问题的表征。解决问题应该尽量在靠近问题的源头的地方来解决问题。
    所以我想尽量使用方案二来解决问题,所以接下来思考的问题是,如何在一次触摸事件分发的过程中,提前结束这次事件。

最终解决方案#

最终解决的方案的灵感来源来自于 RecyclerView 内部的 setLayoutFrozen 方法的实现方案。该方法的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void setLayoutFrozen(boolean frozen) {
if (frozen != mLayoutFrozen) {
assertNotInLayoutOrScroll("Do not setLayoutFrozen in layout or scroll");
if (!frozen) {
mLayoutFrozen = false;
if (mLayoutWasDefered && mLayout != null && mAdapter != null) {
requestLayout();
}
mLayoutWasDefered = false;
} else {
final long now = SystemClock.uptimeMillis();
MotionEvent cancelEvent = MotionEvent.obtain(now, now,
MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
onTouchEvent(cancelEvent);
mLayoutFrozen = true;
mIgnoreMotionEventTillDown = true;
stopScroll();
}
}
}

这段代码在变量为 frozen 的时候,会通过 MotionEvent.obtain 方法来生成一个新的触摸事件,并调用 onTouchEvent 方法来响应这个事件。
我之后尝试在触发长按事件的 View 中也照着这样来处理,然后发现并不可行,RecyclerView 还是会随手势滚动,这意味着这个事件并没有终结。
然后我发现这样尝试的问题所在,因为我只是调用 View 的 onTouchEvent 来处理触摸取消事件,所以这个取消事件只在这个 View 内部消化了,而身为它外部容器的 RecyclerView 其实并没有收到这个事件,所以我想着,不妨在最根部的节点的 ViewGroup 中将这个触摸取消的事件来分发下去。于是写了一个 View 的 kotlin 的扩展方法:

1
2
3
4
5
6
7
fun Activity?.cancelTouchAction() {
this ?: return
val now = SystemClock.uptimeMillis()
val cancelEvent = MotionEvent.obtain(now, now,
MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0)
this.window?.decorView?.dispatchTouchEvent(cancelEvent)
}

然后在弹出 Dialog 的代码后面调用这个方法:

1
2
...(弹出 Dialog 的代码)
(context as? Activity)?.cancelTouchAction()

编译运行,发现问题解决了。