本文首发于公众号:移动开发那些事 Android开发者必备:手势冲突处理实用总结
在 Android 开发中,手势交互是提升用户体验的重要方式,但当多个手势识别器同时作用于同一视图或视图层级时,常常会引发手势冲突问题。本文将深入剖析手势冲突的本质与原理,并提供系统化的解决方案。
1 冲突的原因
冲突的本质原因是:有多个手势识别器同时监听了同一区域的触摸事件,系统无法确定谁是最合适的处理触摸事件的。那为什么会出现冲突呢? 在这之前,我们先来回顾一下Android
事件分发的机制:Android
所有的事件分发都是基于MotionEvent
的传递机制:
- 事件传递:
Activity
→Window
→ViewGroup
→子 View
;- 顶层元素
Activity
接收触摸事件; - 调用
dispatchTouchEvent()
向下传递事件; - 决定事件传递路径和顺序;
- 顶层元素
- 事件拦截:
ViewGroup
可在onInterceptTouchEvent
方法里决定是否拦截事件;- 通过
onInterceptTouchEvent()
决定是否截获事件; - 返回
true
表示拦截事件,不再向下传递
- 通过
- 事件消费:每一层
View
都可以决定是否处理和消耗处理事件;- 最底层视图通过 onTouchEvent() 处理事件;
- 返回
true
表示事件被消费,不再向上传递; - 返回
false
则事件会回溯到父容器
常见的冲突场景有:
- 父子视图冲突:
ScrollView
内嵌ListView
(滚动组件嵌套,滑动时都想争夺事件的处理权) - 同级视图冲突:
ViewPager
内嵌横向RecyclerView
(滑动方向的冲突,一个想横向,一个想竖向) - 多点触控冲突 : 多指同时操作不同手势 (手势识别混乱)
- 系统手势冲突 : 边缘滑动与应用内菜单冲突 (系统返回与应用菜单同时触发)
2 常见的冲突解决方案
2.1 事件分发控制
通过重写以下方法控制事件分发:
dispatchTouchEvent
:控制事件分发方向onInterceptTouchEvent
:ViewGroup
拦截事件的关键方法onTouchEvent
:View
处理事件的核心方法
2.1.1 父容器拦截法
通过在父容器里判断是否要拦截对应的事件
scala
public class CustomViewGroup extends ViewGroup {
private float startX, startY;
private final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
startX = ev.getX();
startY = ev.getY();
// 注意,DOWN事件不能拦截,否则后续即使不拦截其他事件,子View也收不到其他的MotionEvent事件
return false;
case MotionEvent.ACTION_MOVE:
float dx = Math.abs(ev.getX() - startX);
float dy = Math.abs(ev.getY() - startY);
// 水平滑动距离大于垂直时拦截
if (dx > dy && dx > touchSlop) {
// 拦截事件,阻止MOVE事件继续向下传递
return true;
}
break;
}
return super.onInterceptTouchEvent(ev);
}
}
这种处理方式,适用场景:
ScrollView
嵌套ListView/RecyclerView
ViewPager
内嵌横向滑动的子视图- 自定义
ViewGroup
包含多种手势的子视图
2.1.2 子视图请求不拦截
通过使用方法getParent().requestDisallowInterceptTouchEvent(是否拦截);
让父容器不拦截事件,则子视图决定怎样处理事件
scala
/// 子View
public class CustomChildView extends View {
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true); // 请求不拦截
break;
}
return super.dispatchTouchEvent(event);
}
}
/// 父容器
public class ParentView extends FrameLayout {
// 父容器中根据条件决定是否允许拦截
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 这里根据事件情况,判断是否要拦截
if (childView.shouldNotIntercept()) {
return false;
}
return super.onInterceptTouchEvent(ev);
}
}
适用场景
- 地图视图嵌套在可滑动容器中
- 图片缩放控件位于可滚动容器内
- 需要优先处理特定子视图的手势操作
2.2 自定义手势协调器
利用GestureDetector.SimpleOnGestureListener
的回调来控制手势的优先级(这里会使用到GestureDetectorCompat
) 例如当同时拥有滚动和缩放功能时,要怎样来协调对应的手势呢?
scala
public class GestureArbitrator implements View.OnTouchListener {
// 滚动的手势
private GestureDetectorCompat scrollGesture;
// 缩放的手势
private GestureDetectorCompat zoomGesture;
// 是否要缩放中
private boolean isZoomActive;
public GestureArbitrator(Context context) {
scrollGesture = new GestureDetectorCompat(context, new ScrollListener());
zoomGesture = new GestureDetectorCompat(context, new ZoomListener());
}
@Override
public boolean onTouch(View v, MotionEvent event) {
boolean handled = false;
// 也可以在这里按照自己业务的定义来做不同的手势的协调;
// 例如: 双指操作时优先处理缩放
if (event.getPointerCount() == 2) {
handled = zoomGesture.onTouchEvent(event);
isZoomActive = true;
}
// 单指操作时处理滚动
else if (!isZoomActive) {
handled = scrollGesture.onTouchEvent(event);
}
// 重置状态
if (event.getActionMasked() == MotionEvent.ACTION_UP) {
isZoomActive = false;
}
return handled;
}
private class ScrollListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2,
float distanceX, float distanceY) {
// 处理滚动逻辑
return true;
}
}
private class ZoomListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
// 处理缩放逻辑
return true;
}
}
}
还会其他该方案的异化版本,如在ViewDragHelper.Callback
中控制拖拽优先级,这些版本的核心其实都是在onTouch(View v, MotionEvent event)
方法里根据业务的情况,调度不同的手势处理器进行处理,这里就不再讲述了,想要了解的朋友可自行摸索;
2.3 使用 NestedScrolling 机制
NestedScrolling
的核心思想就是:协作式滚动:子视图(如 RecyclerView
)主动发起滚动请求,父容器(如CoordinatorLayout
)可参与决策滚动行为,实现滑动动画的同步与优先级控制; 它工作的大致流程为:
- 1 子视图调用
startNestedScroll()
向父容器申请滚动权限。 - 2 子视图通过
dispatchNestedPreScroll()`` 通知父容器优先处理滚动(如折叠
Toolbar`); - 3 子视图自身滚动后,通过
dispatchNestedScroll()
通知父容器剩余未消耗的滚动量; - 4 滚动结束后,子视图通过
dispatchNestedPostScroll()
允许父容器处理额外逻辑(如FAB
动画); 这一机制已集成在标准组件中(如RecyclerView
,NestedScrollView
,CoordinatorLayout
),但我们也可通过自定义的形式来实现自己的NestedScrolling
组件
typescript
// 父容器实现 NestedScrollingParent
public class CustomNestedScrollView extends ViewGroup implements NestedScrollingParent3 {
@Override
public boolean onStartNestedScroll(View child, View target, int axes, int type) {
// 确定是否响应嵌套滚动
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public void onNestedScrollAccepted(View child, View target, int axes, int type) {
// 准备处理嵌套滚动
}
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed, int type) {
// 先于子视图处理滚动 父容器优先处理预滚动
if (canScrollVertically(dy)) {
scrollBy(0, dy);
consumed[1] = dy; // 标记已消费的滚动距离
}
}
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, int type) {
// 处理子视图未消费的滚动
if (dyUnconsumed < 0 && canScrollVertically(dyUnconsumed)) {
scrollBy(0, dyUnconsumed);
}
}
}
// 子视图实现 NestedScrollingChild,这里实现NestedScrollingChild3 接口
// (这里甚至可以直接继承RecyclerView来做)
public class CustomNestedScrollChild extends View implements NestedScrollingChild3 {
private final NestedScrollingChildHelper mNestedScrollingChildHelper;
public CustomNestedScrollChild(Context context) {
this(context, null);
mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
// 关键点:启用嵌套滚动
setNestedScrollingEnabled(true);
}
@Override
public boolean startNestedScroll(int axes, int type) {
return mNestedScrollingChildHelper.startNestedScroll(axes, type);
}
@Override
public void stopNestedScroll(int type) {
mNestedScrollingChildHelper.stopNestedScroll(type);
}
@Override
public boolean hasNestedScrollingParent(int type) {
return mNestedScrollingChildHelper.hasNestedScrollingParent(type);
}
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed,
int[] offsetInWindow, int type,
@NonNull int[] consumed) {
return mNestedScrollingChildHelper.dispatchNestedScroll(
dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
offsetInWindow, type, consumed
);
}
@Override
public boolean dispatchNestedPreScroll(int dx, int dy,
@Nullable int[] consumed,
@Nullable int[] offsetInWindow,
int type) {
return mNestedScrollingChildHelper.dispatchNestedPreScroll(
dx, dy, consumed, offsetInWindow, type
);
}
// 触摸事件中触发滚动
@Override
public boolean onTouchEvent(MotionEvent event) {
// 这里可按需要进行处理了
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 开始嵌套滚动(垂直方向)
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
break;
case MotionEvent.ACTION_MOVE:
int dy = (int) (event.getY() - lastY);
int[] consumed = new int[2];
// 优先让父容器处理滚动
dispatchNestedPreScroll(0, dy, consumed, null, ViewCompat.TYPE_TOUCH);
// 子视图自身滚动
scrollBy(0, dy - consumed[1]);
break;
case MotionEvent.ACTION_UP:
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
}
return true;
}
}
比较推荐使用NestedScrolling
机制来处理手势冲突问题
3 应用示例
下面我们以如何实现全屏视频播放器手势控制为例来说明怎样去协调手势。需要根据用户的手势的不同来实现不同的播放器的控制功能,包括屏幕亮度调整,声音调整,视频进度拖动;
csharp
public class VideoGestureView extends View {
private enum GestureMode { NONE, VOLUME, BRIGHTNESS, SEEK }
private GestureMode currentMode = GestureMode.NONE;
private float startX, startY;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
startX = event.getX();
startY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
if (currentMode == GestureMode.NONE) {
// 这里实现比较简单,在真实业务中可能会有更复杂的业务条件判断
// 根据位置决定手势类型
if (event.getX() < getWidth() / 3) {
currentMode = GestureMode.BRIGHTNESS;
} else if (event.getX() > getWidth() * 2 / 3) {
currentMode = GestureMode.VOLUME;
} else {
currentMode = GestureMode.SEEK;
}
}
// 处理手势
float deltaX = event.getX() - startX;
float deltaY = event.getY() - startY;
switch (currentMode) {
// 亮度
case BRIGHTNESS:
adjustBrightness(deltaY);
break;
// 声音
case VOLUME:
adjustVolume(-deltaY); // 上滑增加音量
break;
// 拖动进度
case SEEK:
seekVideo(deltaX);
break;
}
break;
case MotionEvent.ACTION_UP:
currentMode = GestureMode.NONE;
break;
}
return true;
}
}
4 总结
本文主要从Android
中手势冲突的原理出发,介绍了常见的几种处理手势冲突的方式,以及适用的场景,这里也提供一些在面临手势冲突时,选择处理方案时可参考的几个点:
- 明确手势的优先级: 确定冲突时不同手势的优先级;
- 合理使用拦截机制:通过
onInterceptTouchEvent
和requestDisallowInterceptTouchEvent
控制事件流向; - 优先使用现代方案:对于嵌套滑动场景,优先采用
NestedScrolling
机制,如NestedScrollView
,CoordinatorLayout
等; - 适应系统特性:在
Android 10+
使用手势排除 API 协调系统手势;
最后以一张手势冲突处理的决策树来结束:
