Android事件分发机制与嵌套导致触摸事件冲突的解决方案
实现滑动的常用方法
- 通过
scrollTo()
、scrollBy()
来进行滑动 - 使用Scroller来进行滑动
@Override public boolean onTouchEvent(MotionEvent event) { case ACTION_UP: scroller.startScroll(getScrollX(), 0, dx, 0); invalidate(); break; } return super.onTouchEvent(event); } @Override public void computeScroll() { if (scroller.computeScrollOffset()) { scrollTo(scroller.getCurrX(), scroller.getCurrY()); invalidate(); } }
上面的代码基本上就是使用Scroller的基本套路,首先调用startScroll(int startX, int startY, int dx, int dy)
,然后重写computeScroll()
方法获取Scroller计算出来的X、Y坐标后调用scrollTo(int x, int y)
进行滑动。
- 使用属性动画来进行滑动
事件分发源码流程
事件分发的流程图:
首先我们带着几个问题去看源码,不然会被源码的很多细枝末节给干扰到。
1. MotionAction的起点是什么地方?
触屏事件最先传递的是Activity,因此事件分发的起点是Activity的dispatchTouchEvent方法。
/** * Called to process touch screen events. You can override this to * intercept all touch screen events before they are dispatched to the * window. Be sure to call this implementation for touch screen events * that should be handled normally. * * @param ev The touch screen event. * * @return boolean Return true if this event was consumed. */ public boolean dispatchTouchEvent(MotionEvent ev) { ... if (getWindow().superDispatchTouchEvent(ev)) { return true; } return onTouchEvent(ev); }
从这段代码能够看到Activity会调用getWindow()的superDispatchTouchEvent((MotionEvent ev)方法,这里我们知道Android的Window对象只有一个实现类PhoneWindow,所以我们再看PhonwView的superDispatchTouchEvent((MotionEvent ev)方法。
// This is the top-level view of the window, containing the window decor. private DecorView mDecor; @Override public boolean superDispatchTouchEvent(MotionEvent event) { return mDecor.superDispatchTouchEvent(event); }
这里可以看到实际上交给了DecorView的superDispatchTouchEvent((MotionEvent ev)方法来处理,比较了解DecorView的可以知道它实际上是一个封装的FrameLayout,也就是一个一个Top-level的ViewGroup,从这里就是我们熟悉的View事件分发机制的开始。
2. ViewGroup是如何将事件分发给子View的?
我们先忽略ViewGroup的拦截事件的调用,先考虑最简单的情况。
for (int i = childrenCount - 1; i >= 0; i--) { final int childIndex = getAndVerifyPreorderedIndex( childrenCount, i, customOrder); final View child = getAndVerifyPreorderedView( preorderedList, children, childIndex); ... if (!canViewReceivePointerEvents(child) || !isTransformedTouchPointInView(x, y, child, null)) { ev.setTargetAccessibilityFocus(false); continue; } ... resetCancelNextUpFlag(child); if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { // Child wants to receive touch within its bounds. mLastTouchDownTime = ev.getDownTime(); if (preorderedList != null) { // childIndex points into presorted list, find original index for (int j = 0; j < childrenCount; j++) { if (children[childIndex] == mChildren[j]) { mLastTouchDownIndex = j; break; } } } else { mLastTouchDownIndex = childIndex; } mLastTouchDownX = ev.getX(); mLastTouchDownY = ev.getY(); newTouchTarget = addTouchTarget(child, idBitsToAssign); alreadyDispatchedToNewTouchTarget = true; break; } ... }
从代码中我们能够看到将事件分发给子View的方式就是执行一次遍历执行dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)
方法,如果这个方法返回了true的会就会立刻停止继续遍历,触屏事件也将会由这个子View进行消费。我们可以详细的看下dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)
到底做了什么?
/** * Transforms a motion event into the coordinate space of a particular child view, * filters out irrelevant pointer ids, and overrides its action if necessary. * If child is null, assumes the MotionEvent will be sent to this ViewGroup instead. */ private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desiredPointerIdBits) { final boolean handled; ... // Handle an initial down. if (actionMasked == MotionEvent.ACTION_DOWN) { // Throw away all previous state when starting a new touch gesture. // The framework may have dropped the up or cancel event for the previous gesture // due to an app switch, ANR, or some other state change. cancelAndClearTouchTargets(ev); resetTouchState(); } // Perform any necessary transformations and dispatch. if (child == null) { handled = super.dispatchTouchEvent(transformedEvent); } else { final float offsetX = mScrollX - child.mLeft; final float offsetY = mScrollY - child.mTop; transformedEvent.offsetLocation(offsetX, offsetY); if (! child.hasIdentityMatrix()) { transformedEvent.transform(child.getInverseMatrix()); } handled = child.dispatchTouchEvent(transformedEvent); } // Done. transformedEvent.recycle(); return handled; }
从代码能够看到如果child不等于null,将会调用child的dispatchTouchEventdispatchTouchEvent(MotionEvent event)
方法。
继续进行childView的事件分发流程。
这里我们还要关注一下遍历时addTouchTarget(child, idBitsToAssign);
这个方法具体的作用是什么?
// First touch target in the linked list of touch targets. private TouchTarget mFirstTouchTarget; /** * Adds a touch target for specified child to the beginning of the list. * Assumes the target child is not already present. */ private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) { final TouchTarget target = TouchTarget.obtain(child, pointerIdBits); target.next = mFirstTouchTarget; mFirstTouchTarget = target; return target; }
从代码我们能够看到这个又一个TouchTarget的链表,我们在dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)
返回true之后,会给mFirstTouchTarget进行赋值,此时mFirstTouchTarget将会不为null。
当然,在执行dispatchTransformedTouchEvent
前会过滤掉一些不可能接收到触屏事件的子View。
if (!canViewReceivePointerEvents(child) || !isTransformedTouchPointInView(x, y, child, null)) { ev.setTargetAccessibilityFocus(false); continue; }
从代码我们能够看到主要是判断了两点
1. 点击的Point是否在子View的区域内。
2. 子View是否正在执行动画。
如果符合上述的任意一点就会直接continue,进行循环的下一个项。
3. 什么情况下会触发ViewGroup的onInterceptTouchEvent((MotionEvent ev)方法?
// Check for interception. final boolean intercepted; if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) { final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0; if (!disallowIntercept) { intercepted = onInterceptTouchEvent(ev); ev.setAction(action); // restore action in case it was changed } else { intercepted = false; } } else { // There are no touch targets and this action is not an initial down // so this view group continues to intercept touches. intercepted = true; }
从代码我们能够看到执行 onInterceptTouchEvent(ev) 的条件这么几个,我们一个一个来看代表什么。
-
actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null
actionMasked == MotionEvent.ACTION_DOWN
这个很好理解如果触屏事件是ACTION_DONW的话条件就能成立。mFirstTouchTarget != null
从上面的源码分析我们可以知道,mFirstTouchTarget!=null
就代表着触屏事件之前已经被子View给消费过了,剩余的事件序列将会全部交由那个View来处理。
-
!disallowIntercept
这里我们可以看代码
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
这里有个标志FLAG_DISALLOW_INTERCEPT
,如果这个标志不为0的话,disallowIntercept
就可以为true,此时!disallowIntercept
将会不成立,那么如果让这个值为true呢?这里就牵扯到一个方法,requestDisallowInterceptTouchEvent(boolean disallowIntercept)
,通过这个方法我们就能够让父View不区拦截子View的事件,因此这个方法也是我们解决事件冲突的方式之一。
4. 能否让ViewGroup的不拦截所有的触屏事件?
这个问题其实是否定的,从代码我们就能够看到
// Handle an initial down. if (actionMasked == MotionEvent.ACTION_DOWN) { // Throw away all previous state when starting a new touch gesture. // The framework may have dropped the up or cancel event for the previous gesture // due to an app switch, ANR, or some other state change. cancelAndClearTouchTargets(ev); resetTouchState(); } /** * Resets all touch state in preparation for a new cycle. */ private void resetTouchState() { clearTouchTargets(); resetCancelNextUpFlag(this); mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT; mNestedScrollAxes = SCROLL_AXIS_NONE; }
从代码我们能够看到当触屏事件是ACTION_DOWN时会重置全部的TouchTargets和FLAG_DISALLOW_INTERCEPT,因此触屏事件是
ACTION_DOWN时,父ViewGroup将会必定执行onInterceptTouchEvent(ev)
方法。
5. setOnTouchListneer后还会不会触发onTouchEvent((MotionEvent ev)方法?
我们可以分析View的dispatchTouchEvent(MotionEvent event)
方法
ListenerInfo li = mListenerInfo; if (li != null && li.mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED && li.mOnTouchListener.onTouch(this, event)) { result = true; } if (!result && onTouchEvent(event)) { result = true; }
从这段代码我们可以知道如果mOnTouchListener!=null
,那么执行mOnTouchListener.onTouch(this, event)
,如果返回true,那么就是result =true
,这时onTouchEvent(event)
便不会再执行了,所以onTouchEvent(event)
是否还执行需要看mOnTouchListener.onTouch(this, event)
的返回值。
6. 某个View一旦开始处理事件,那么同一个事件序列的事件还会交给其它View处理吗?
// Check for interception. final boolean intercepted; if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) { final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0; if (!disallowIntercept) { intercepted = onInterceptTouchEvent(ev); ev.setAction(action); // restore action in case it was changed } else { intercepted = false; } } else { // There are no touch targets and this action is not an initial down // so this view group continues to intercept touches. intercepted = true; } // Dispatch to touch targets. if (mFirstTouchTarget == null) { // No touch targets so treat this as an ordinary view. handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS); } else { // Dispatch to touch targets, excluding the new touch target if we already // dispatched to it. Cancel touch targets if necessary. TouchTarget predecessor = null; TouchTarget target = mFirstTouchTarget; while (target != null) { final TouchTarget next = target.next; if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) { handled = true; } else { final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted; if (dispatchTransformedTouchEvent(ev, cancelChild, target.child, target.pointerIdBits)) { handled = true; } if (cancelChild) { if (predecessor == null) { mFirstTouchTarget = next; } else { predecessor.next = next; } target.recycle(); target = next; continue; } } predecessor = target; target = next; } }
从这段我们可以知道如果mFirstTouchTarget != null
,那么触摸事件ViewGroup将不会进行拦截,同时将会遍历mFirstTouchTarget
执行dispatchTransformedTouchEvent()
。
什么时候会执行ViewGroup
的onTouchEvent()
当mFirstTouchTarget
为null时才会调用ViewGroup的onTouchEvent()方法。也就是说有两种可能
1. ACTION_DOWN时ViewGroup的onInterceptTouchEvent()
返回为true。
2. 遍历调用了一遍child的dispatchTouchEvent()
触摸事件都没有被消费掉,此时也会执行ViewGroup的onTouchEvent()
嵌套导致滑动事件冲突的解决方法
-
自己处理事件分发的过程解决冲突
这种方式需要自己处理事件的分发与拦截,比较复杂。同时又分为外部处理与内部处理两种。对开发者事件分发机制理解的要求比较的高。
简单举个面试经常会被问的问题,
ScrollView嵌套ListView
该如何处理,正常情况下滑动事件会被ScrollView
拦截消费,不过一般来说ViewGroup都不会拦截ACTION_DONE
事件时调用requestDisallowInterceptTouchEvent(true);
让ScrollView
不拦截滑动事件,于是滑动事件变能够正常传递给ListView
,ListView也就能够正常进行滑动了。@Overridepublic boolean dispatchTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: mLastY = ev.getY(); requestDisallowInterceptTouchEvent(true); break; return super.dispatchTouchEvent(ev);}
但是这种方式也存在瑕疵,如果ScrollView中存在一个HeadView,当ListView已经滑动到最上方时,如果将互动的事件转交给ScrollView呢?正常的事件分发一旦某个View消费了事件,那么事件序列接下来的事件都会被他消费掉,此时我们可以手动的将事件再传递给
ScrollView
不过自己来写的话耦合会非常的大,类似这样的:case MotionEvent.ACTION_MOVE: boolean isMoveUp = mLastY - ev.getY() < 0; if (isMoveUp && firstVisiblePosition == 0) { Log.d("tset", "needParent consume!"); requestDisallowInterceptTouchEvent(false); ((ViewGroup)getParent()).onTouchEvent(ev); return false; } else if (!isMoveUp && lastVisiblePosition == getAdapter().getCount() - 1) { Log.d("tset", "needParent consume!"); requestDisallowInterceptTouchEvent(false); ((ViewGroup)getParent()).onTouchEvent(ev); return false; } break;
于是Android又给我们提供了基于NestedScrolling的解决方案。
-
基于NestedScrolling的实现方案
NestedScrolling主要的思想是:Child来做事件的接收者,当Child接收到Move事件时,先调用
dispatchNestedPreScroll()
把事件交给Parent来处理,Parent将会收到回调onNestedPreScroll()
此时Parent判断是否需要消耗事件,消耗多少距离的滑动,之后Child根据剩余未消耗掉的距离继续执行滑动。 -
基于CoordinatorLayout、Behavior的实现方案(后续再专门写一篇总结)
参考资料
Android Scroller完全解析,关于Scroller你所需知道的一切
<>:任玉刚
Android嵌套滑动机制
NestedScrolling帮你实现一个简单的嵌套滑动
Android NestedScrolling机制完全解析 带你玩转嵌套滑动
Awesome-Android-Interview
更多相关文章
- android响应事件(按钮)的三种方式
- Android通过Termux安装scrapy遇到的问题和解决方法
- Ubuntu 编译Android若干错误及解决方法
- android中获取验证码后出现60秒的倒计时
- Android(安卓)Native 应用程序启动 Activity 的方法
- Android面试题集锦(四)
- 《Android(安卓)Framework 之路》Android5.1 Camera Framework(一
- 使用RecyclerView加载不出数据的原因可能有:
- Android:Textview 通过代码设置 Drawable