一、View的事件分发
1、点击事件的传递规则
在介绍点击事件的传递规则之前,首先明白分析的对象就是MotionEvent,即点击事件。所谓点击事件的事件分发,其实就是对MotionEvent事件的分发过程,即当一个MoonEvent产生了以后,系统需要把这个事件传递给一个具体的View,而这个传递的过程就是分发过程。点击事件的分发过程由三个很重要的分发来完成dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent,下面我们先介绍一下这几个方法。
上述三个方法到底有什么区别呢?它们是什么关系呢?其实它们的关系可以用如下伪代码可以了解
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if(onInterceptTouchEvent(ev)){
consume = onTouchEvent(ev);
}else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
上述的伪代码已经将三者的区别说明了,我们可以大致的了解传递的规则就是,对于一个根ViewGroup来说,点击事件产生以后,首先传递给它,这时它的dispatchTouchEvent就会被调用,如果这个ViewGroup的onIntereptTouchEvent方法返回true,就表示它要拦截当前事件,接着事件就会交给这个ViewGroup处理,则他的onTouchEvent方法就会被调用;如果这个ViewGroup的onIntereptTouchEvent方法返回false就表示不需要拦截当前事件,这时当前事件就会继续传递给它的子元素,接着子元素的onIntereptTouchEvent方法就会被调用,如此反复直到事件被最终处理。
关于事件传递的机制,这里给出一些结论,根据这些结论可以更好地理解整个传递机制,如下所示。
(1)同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏慕的那一刻结束,在这个过程中所产生的一系列事件,这个事件序列以down事件开始,中间含有数量不定的move事件,最后以up结束。
(2)正常情况下,一个事件序列只能被一个View拦截且消耗。这一条的原因可以参考(3),因为一旦一个元素拦截了某此事件,那么同一个事件序列内的所有事件都会直接交给它处理,因此同一个事件序列中的事件不能分别由两个View同时处理,但是通过特殊手段可以做到,比如一个Vew将本该自己处理的事件通过onTouchEvent强行传递给其他View处理。
(3)某个View一旦决定拦截,那么这一个事件序列都只能由它来处理(如果事件序列能够传递给它的话),并且它的onInterceprTouchEvent不会再被调用。这条也很好理解,就是说当一个View决定拦截一个事件后,那么系统会把同一个事件序列内的其他方法都直接交给它来处理,因此就不用再调用这个View的onInterceptTouchEvent去询问它是否要拦截了。
(4)**某个View一旦开始处理事件,如果它不消耗ACTON_DOWN事件(onTouchEvent返回了false),那么同一事件序列中的其他事件都不会再交给它来处理,并且事件将重新交由它的父元素去处理,即父元素的onTouchEvent会被调用。**意思就是事件一旦交给一个View处理,那么它就必须消耗掉,否则同一事件序列中剩下的事件就不再交给它来处理了,这就好比上级交给程序员一件事,如果这件事没有处理好,短期内上级就不敢再把事情交给这个程序员做了,二者是类似的道理。
(5)如果View不消耗除ACTION_DOWN以外的其他事件,那么这个点击事件会消失,此时父元素的onTouchEvent并不会被调用,并且当前View可以持续收到后续的事件,最终这些消失的点击事件会传递给Activity处理。
(6) ViewGroup默认不拦截任何事件。Android源码中ViewGroup的onInterceptTouchEvent方法默认返回false
(7)View没有onInterceptTouchEvent方法,一旦有点击事件传递给它,那么它的onTouchEvent方法就会被调用。
(8)view的onTouchEvent默认都会消耗事件(返回true),除非它是不可点击的(clickable和longClickable同时为false),View的longClickable属性默认都为false,clickable属性要分情况,比如Button的clickable属性默认为true,而TextView 的clickable属性默认为false
(9) view 的enable属性不影响onTouchEvent的默认返回值。哪怕一个View是disable状态的,只要它的clickable或者longclickable有一个为true,那么它的onTouchEvent就返会true。
(10)onClick会发生的前提实际当前的View是可点击的,并且他收到了down和up的事件
(11) 事件传递过程是由外到内的,理解就是事件总是先传递给父元素,然后再由父元素分发给子View,通过requestDisallowInterptTouchEvent方法可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN除外。
二、事件分发的源码解析
1.Activity对点击事件的分发过程
//Activity#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(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;
}
ViewGroup不拦截事件的时候,事件会向下分发由他的子View进行处理
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// If there is a view that has accessibility focus we want it
// to get the event first and if not handled we will perform a
// normal dispatch. We may do a double iteration but this is
// safer given the timeframe.
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
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;
}
3.View对点击事件的处理
View对点击事件的处理稍微有点简单, 这里注意,这里的View不包含ViewGroup,先看他的dispatchTouchEvent方法
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
...
if (onFilterTouchEventForSecurity(event)) {
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
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;
}
}
...
return result;
}
因为他只是一个View,他没有子元素所以无法向下传递,所以只能自己处理点击事件,从上门的源码可以看出View对点击事件的处理过程,首选会判断你有没有设置onTouchListener**,如果onTouchListener中的onTouch为true,那么onTouchEvent就不会被调用,可见onTouchListener的优先级高于onTouchEvent,这样做到好处就是方便在外界处理点击事件**;
接着我们再来分析下onTouchEvent的实现,先看当View处于不可用的状态下点击事件的处理过程,如下,很显然,不可用状态下的View照样会消耗点击事件,尽管他看起来不可用。
下面再看一下onTouchEvent中点击事件的具体处理,如下所示:
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
(viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
switch (action) {
case MotionEvent.ACTION_UP:
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
// take focus if we don't have it already and we should in
// touch mode.
boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
focusTaken = requestFocus();
}
if (prepressed) {
// The button is being released before we actually
// showed it as pressed. Make it show the pressed
// state now (before scheduling the click) to ensure
// the user sees it.
setPressed(true, x, y);
}
if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
// This is a tap, so remove the longpress check
removeLongPressCallback();
// Only perform take click actions if we were in the pressed state
if (!focusTaken) {
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
if (!post(mPerformClick)) {
performClick();
}
}
}
black;
}
....
return true;
}
public boolean performClick() {
final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
return result;
}
public void setOnClickListener(@Nullable OnClickListener l) {
if (!isClickable()) {
setClickable(true);
}
getListenerInfo().mOnClickListener = l;
}
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
if (!isLongClickable()) {
setLongClickable(true);
}
getListenerInfo().mOnLongClickListener = l;
}
二、View的滑动冲突
滑动冲突时怎样产生的?其实在界面中只要内外两层同时可以滑动,这个时候就会产生滑动冲突,如何解决滑动冲突?这既是一键困难的事情又是一件简单的事情,说困难时因为许多开发者面对滑动冲突都会显得束手无策,说简单是因为滑动冲突的解决办法有固定的套路。
1.常见的滑动冲突场景
先说场景1,主要是将ViewPager和Fragment配合使用所组成的页面滑动效果,主流应用几乎都会使用这个效果。在这种效果中,可以通过左右滑动来切换页面,而每个页面内部往往又是一个Listview。本来这种情况下是有滑动冲突的,但是viewPager内部处理了这种滑动冲突,因此采用ViewPager时我们无须关注这个问题,如果我们采用的不是ViewPager而是ScrollView等,那就必须手动处理滑动冲突了,否则造成的后果就是内外两层只能有一层能够滑动,这是因为两者之间的滑动事件有冲突。除了这种典型情况外,还存在其他情况,比如外部上下滑动、内部左右滑动等,但是它们属于同一类滑动冲突。
再说场景2,这种情况就稍微复杂一些,当内外两层都在同一个方向可以滑动的时候,显然存在逻辑问题。因为当手指开始滑动的时候,系统无法知道用户到底是想让哪一层滑动,所以当手指滑动的时候就会出现问题,要么只有一层能滑动,要么就是内外两层都滑动得很卡顿。在实际的开发中,这种场景主要是指内外两层同时能上下滑动或者内外两层,同时能左右滑动。
最后说下场景3,场景3是场景1和场景2两种情况的嵌套,因此场景3的滑动冲突看起来就更加复杂了。比如在许多应用中会有这么一个效果:内层有一个场景1中的滑动效果,然后外层又有一个场景2中的滑动效果。具体说就是,外部有一个SlidingMenu效果,然后内部有一个ViewPager,ViewPager的每一个页面中又是一个Listview。虽然说场景3的滑动冲突看起来更复杂,但是它是几个单一的滑动冲突的叠加,因此只需要分别处理内层和中层、中层和外层之间的滑动冲突即可,而具体的处理方法其实是和场景1、场景2相同的。
从本质上来说,这三种滑动冲突场景的复杂度其实是相同的,因为它们的区别仅仅是滑动策略的不同,至于解决滑动冲突的方法,它们几个是通用的。
2.滑动冲突的处理规则
主要有2种:外部拦截法(推荐使用)、内部拦截法。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted = false;//必须不能拦截,如果拦截了,后续move和up都由父元素处理了
break;
case MotionEvent.ACTION_MOVE:
if("父容器需要当前点击事件"){
intercepted = true;
}else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;//必须不能拦截,如果拦截了,子元素的onClick点击事件就无法触发
break;
}
mLastXIntercept = x;
mLastYIntercept = x;
return intercepted;
}
b.内部拦截法
内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和Android中的事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起来较外部拦截法稍显复杂。它的伪代码如下,我们需要重写子元素的dispatchTouchEvent方法:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);//设置父容器不允许拦截
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = x - mLastY;
if("父容器需要此类点击事件"){
getParent().requestDisallowInterceptTouchEvent(false);//设置拦截
}
break;
case MotionEvent.ACTION_UP:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);//这个值取决于子view的处理结果,
//例如这个view如果继承ListView,就需要看listview的这个方法处理结果。并不是再调用了父容器的这个方法。
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if(action == MotionEvent.ACTION_DOWN){
return false;//不拦截
}else {
return true;
}
}
总结下:使用外部拦截只需要处理父容器的OnInterceptTouchEvent方法。使用内部拦截需要处理子容器的dispatchTouchEvent和重写父元素的OnInterceptTouchEvent方法。所以建议使用外部拦截法会比较方便。
下面演示一个外部拦截法的实例:
自定义一个HorizontalScrollViewEx
package com.example.viewsample;
import android.content.Context;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewGroup;
import android.widget.Scroller;
public class HorizontalScrollViewEx extends ViewGroup {
public static final String TAG = "HorizontalScrollViewEx";
private int mChindrensize;
private int mChindrenWidth;
private int mChindrenIndex;
//分别记录上次滑动的坐标
private int mLastX = 0;
private int mLastY = 0;
//分别记录上次滑动的坐标
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
public HorizontalScrollViewEx(Context context) {
super(context);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = true;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltax = x - mLastXIntercept;
int deltaY = y = mLastYIntercept;
if (Math.abs(deltax) > Math.abs(deltaY)) {//父容器的拦截逻辑,水平滑动就拦截
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);//拦截后处理逻辑
break;
case MotionEvent.ACTION_UP:
int scrollX = getScrollX();
int scrollToChildIndex = scrollX / mChindrenWidth;
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChindrenIndex = xVelocity > 0 ? mChindrenIndex - 1 : mChindrenIndex + 1;
} else {
mChindrenIndex = (scrollX + mChindrenWidth / 2) / mChindrenWidth;
}
mChindrenIndex = Math.max(0, Math.min(mChindrenIndex, mChindrensize - 1));
int dx = mChindrenIndex * mChindrenWidth - scrollX;
ssmoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
}
mLastX = x;
mLastY = y;
return true;
}
//这个方法上面一章讲解过,Scroller的弹性滑动原理
private void ssmoothScrollBy(int dx, int i) {
mScroller.startScroll(getScrollX(),0,dx,500);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
}
测试:
public class MainActivity extends AppCompatActivity {
public static final String TAG = "MainActivity";
private HorizontalScrollViewEx mListContainer;
private int w,h;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Log.i(TAG,"onCreate");
initView();
}
private void initView() {
LayoutInflater inflater = getLayoutInflater();
mListContainer = findViewById(R.id.container);
//屏幕宽高
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
w = wm.getDefaultDisplay().getWidth();
h = wm.getDefaultDisplay().getHeight();
for (int i = 0; i < 3; i++) {
ViewGroup layout = inflater.inflate(R.layout.content_layout,mListContainer,false);
layout.getLayoutParams().width = w;
TextView textview = (TextView) layout.findViewById(R.id.title);
textview.setText("page" + (i+1));
layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),0));
createList(layout);
mListContainer.addView(layout);
}
}
private void createList(ViewGroup layout) {
ListView listview = (ListView) layout.findViewById(R.id.list);
ArrayList<String>datas= new ArrayList<>();
for (int i = 0; i < 50; i++) {
datas.add("names" + i);
}
ArrayAdapter<String>adapter = new ArrayAdapter<String>(this,R.layout.content_list_item,R.id.name,datas);
listview.setAdapter(adapter);
}
}
内部拦截参考上面的伪代码自行研究下。滑动冲突解决基本就掌握了。