您的位置:首页 > 移动开发 > Android开发

Android开发艺术探索读书(三)-View的事件体系

2017-04-16 23:52 399 查看
移动手持客户端作为目前最受欢迎的智能设备,拥有着最为广大的体验用户群体。因此,作为软件开发商,要紧紧抓住用户的胃口,不仅要向用户提供合适的服务项目,也应该更为注重与用户的交互体验。而作为感觉型的用户,应用操作是否流畅,界面内容是不是足够精致,是判断该应用是不是一个好应用的硬性标准。那么,要如何去强化与用户的交互体验呢?这就涉及了本章所讲的内容:View的事件体系

一、什么是View

View是Android中所有控件的基类,不管是简单的Button,TextView还是复杂的RelativeLayout还是ListView,实际上都是由View这个基类(父类)派生出来的子类,也就是说,View本质上是界面层中关于控件的一种抽象,而它本身也可以作为一个完整的控件来使用。与之相似的是ViewGroup,即控件组。它不仅代表了一组View的集合,也代表了其本身也是一个View。即是说:ViewGroup是由View派生出来的一个可以容纳一组View对象的View容器。这样看起来似乎很混乱,但实际上,View的结构树就是一种多态的应用。而View与ViewGroup的关系可以用下面一张图进行理解:



二、View的位置参数

我们知道,View的实际实现可以是一个常见的Button。但是这个Button到底是怎样放在某个特定的位置,又是依靠什么来作为参考物的呢?这就涉及了View的位置参数问题。

View的位置参数主要由它的四个顶点来决定,分别对应View的四个属性:top、left、right以及bottom。他们以当前View的父控件作为位置参数,分别代表了上,左,下,右,是相对于父控件(父容器)的坐标体系。他们之间的关系可以表示为:



其中,红色部分代表view与view的父容器的位置关系,而紫色部分则为view的父容器(本质上也是view)与它的父容器之间的位置关系。那么你说,这么下去,那到底谁才是源头?从Activity的组成结构来看,它最高应该可追溯到DecorView之中。而我们由这张图也可以得出两条公式:

width = right - left;
height = buttom - top;


那么,我们要怎样才能获得这四个属性呢?我们看一下源码,发现里面有如下几个属性,分别对应left,right,top,buttom

/**
* The distance in pixels from the left edge of this view's parent
* to the left edge of this view.
* {@hide}
*/
@ViewDebug.ExportedProperty(category = "layout")
protected int mLeft;
/**
* The distance in pixels from the left edge of this view's parent
* to the right edge of this view.
* {@hide}
*/
@ViewDebug.ExportedProperty(category = "layout")
protected int mRight;
/**
* The distance in pixels from the top edge of this view's parent
* to the top edge of this view.
* {@hide}
*/
@ViewDebug.ExportedProperty(category = "layout")
protected int mTop;
/**
* The distance in pixels from the top edge of this view's parent
* to the bottom edge of this view.
* {@hide}
*/
@ViewDebug.ExportedProperty(category = "layout")
protected int mBottom;


而通过追溯源码,我们发现View类为这四个属性分别添加了setter和getter方法,对应的是设置四个属性及获得四个属性的值得方法,即:

setLeft(int left)
setRight(int right)
setTop(int top)
setBottom(int bottom)

left = getLeft();
right = getRight();
top = getTop();
bottom = getBottom();


从android3.0版本之后开始,为了使View的形式更加多样化。View在原有基础上增加了额外的几个参数:

x: view左上角的横坐标

y: View左上角的纵坐标

translationX: view相对于left的偏移量

translationY: view现对于top的偏移量

什么意思呢?请看,当控件不曾移动时,结果如下:



此时,我们得到了left和top的值,那么作为控件左上角的坐标,(X,y)可记为(left,top);

而当我们将控件偏移了一段距离之后:



在这里,我们原本可以直接通过改变top和left来表示偏移后的view的位置,但这样的话就相当于是重新在父容器中添加了一个新的View,而如果该View需要执行的一个动作为:偏移一定距离后,直接返回原来的位置,但是我们原来位置的坐标已经被新的left和top覆盖了,导致的问题就是我们不能准确回到之前的位置。怎么办呢?由此便出现了translationX和translationY。且看,若我们保持left和top不变。那么移动后的控件的左上角坐标(x,y)其实就等价于(left+translationX,top +translationY)。也就是说,当view移动时,我们可以保证left和top的信息不变,而发生改变的就仅仅是x,y,translationX,translationY四个参数了。

三、几个相关的View知识点**

1.MotionEvent:

关于MotionEvent,大家应该不陌生。当我们给一个控件指定setOnTouchListener监听并实现OnTouchListener()接口时,需要重写onTouch()方法,里面就包含了MotionEvent类型的参数。如下:

tvSubmit.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
// TODO Auto-generated method stub
return false;
}
});


MotionEvent可以理解为从手指接触屏幕到离开屏幕后的一系列事件的总和,典型的事件类型有如下三类:

ACTION_DOWN:手指刚接触到view

ACTION_MOVE:手指在view中进行移动

ACTION_UP :手指离开view


以view为整个屏幕为例,如果手指点击屏幕后立刻松开,那么事件序列为:

ACTION_DOWN ——> ACTION_UP

如果手指在点击屏幕后滑动了一会再松开,那么事件序列可能为:

ACTION_DOWN ——> ACTION_MOVE ——> ACTION_MOVE…… ——> ACTION_UP

注意:我们可以通过MotionEvent对象来取得点击事件发生的x和y坐标。而针对不同的场景,系统提供了两种方法,供我们在实际应用中进行调用。它们分别为用于返回相对当前view左上角的x和y的getX/getY方法以及用于返回相对于手机屏幕左上角的x和y的getRawX/getRawY方法。

2.TouchSlop

它是系统所能识别出的被认为是滑动的最小距离,本质上是一个常量(源码中默认为8dp)。也就是说如果两次滑动之间的距离少于这个常量,那么系统就不会判定你在执行滑动操作。进而可以防止某些因为不小心导致的误操作而降低用户的体验感。我们可以通过

ViewConfiguration.get(getContext()).getScaledTouchSlop();


这个方法来获取这个常量。

3.VelocityTracker

用于追踪手指在滑动过程中的速度,公式为 v = (end-start)/s使用过程如下:

//1.在View的onTouchEvent方法中追踪当前事件
VelocityTracker tracker = VelocityTrack.obtain();
tracker.addMovement(event);
//2.获得当前的速度
VelocityTracker.computeCurrentVelocity(1000);//时间间隔为1秒
int velocityX = (int) tracker.getXVelocity();
int velocityY = (int) tracker.getYVelocity();
//当不需要再使用时,进行重置并回收内存
tracker.clear();
tracker.recycle();


4.GestureDetector

这是个比较难掌控的点,用于辅助监测用户的单击、滑动、长按、双击等行为。我们通过查看源码,发现在GestureDetector类中封装了两个接口和一个内部类:



其中,SimpleOnGestureListener继承了以上两个接口,可以算得上是两者的结合。那么,该如何使用这个类呢?我们看一下源码的解释:

* To use this class:
* <ul>
*  <li>Create an instance of the {@code GestureDetector} for your {@link View}
*  <li>In the {@link View#onTouchEvent(MotionEvent)} method ensure you call
*          {@link #onTouchEvent(MotionEvent)}. The methods defined in your callback
*          will be executed when the events occur.
* </ul>
*/


也就是说,要使用GestureDetector,有两个主要步骤:

1.创建一个GestureDetector对象,并实现相关接口或者抽象类,这里以OnGestureListenr为例

GestureDetector gestureDetector = new GestureDetecoter(this,new MyGestureListener());

class MyGestureListener implements OnGestureListenr{
重写的方法 (此处略)
}


2.在目标View的onTouchEvent(MotionEvent)方法中调用GestureDetector的onTouchEvent(MotionEvent)方法来接管目标View。以Button控件为例:

button = (Button) findViewById(R.id.btn_gesture);

button.setOnTouchListener(new OnTouchListener() {

@Override
public boolean onTouch(View v, MotionEvent event) {
boolean consume = gestureDetector.onTouchEvent(event);
return consume;
}
});


完成以上两步,便可有选择地实现两个接口的相关方法了。

相关方法如下:

public interface OnGestureListener {
boolean onDown(MotionEvent e);
void onShowPress(MotionEvent e);
boolean onSingleTapUp(MotionEvent e);
boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY);
void onLongPress(MotionEvent e);
boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY);
}

public interface OnDoubleTapListener {
boolean onSingleTapConfirmed(MotionEvent e);
boolean onDoubleTap(MotionEvent e);
boolean onDoubleTapEvent(MotionEvent e);
}


5.Scroller

用于实现View的弹性滑动,用于防止使用View的scrollTo/scrollBy方法时,因为瞬间实现滑动过程而使得用户的体验不好这个问题。他的实现用法如下:参考郭霖大神的博客代码: Android Scroller完全解析,关于Scroller你所需知道的一切

/**
* Created by guolin on 16/1/12.
*/
public class ScrollerLayout extends ViewGroup {

/**
* 用于完成滚动操作的实例
*/
private Scroller mScroller;

/**
* 判定为拖动的最小移动像素数
*/
private int mTouchSlop;

/**
* 手机按下时的屏幕坐标
*/
private float mXDown;

/**
* 手机当时所处的屏幕坐标
*/
private float mXMove;

/**
* 上次触发ACTION_MOVE事件时的屏幕坐标
*/
private float mXLastMove;

/**
* 界面可滚动的左边界
*/
private int leftBorder;

/**
* 界面可滚动的右边界
*/
private int rightBorder;

public ScrollerLayout(Context context, AttributeSet attrs) {
super(context, attrs);
// 第一步,创建Scroller的实例
mScroller = new Scroller(context);
ViewConfiguration configuration = ViewConfiguration.get(context);
// 获取TouchSlop值
mTouchSlop = ViewConfigurationCompat.getScaledPagingTouchSlop(configuration);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 为ScrollerLayout中的每一个子控件测量大小
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
}
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (changed) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
// 为ScrollerLayout中的每一个子控件在水平方向上进行布局
childView.layout(i * childView.getMeasuredWidth(), 0, (i + 1) * childView.getMeasuredWidth(), childView.getMeasuredHeight());
}
// 初始化左右边界值
leftBorder = getChildAt(0).getLeft();
rightBorder = getChildAt(getChildCount() - 1).getRight();
}
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mXDown = ev.getRawX();
mXLastMove = mXDown;
break;
case MotionEvent.ACTION_MOVE:
mXMove = ev.getRawX();
float diff = Math.abs(mXMove - mXDown);
mXLastMove = mXMove;
// 当手指拖动值大于TouchSlop值时,认为应该进行滚动,拦截子控件的事件
if (diff > mTouchSlop) {
return true;
}
break;
}
return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
mXMove = event.getRawX();
int scrolledX = (int) (mXLastMove - mXMove);
if (getScrollX() + scrolledX < leftBorder) {
scrollTo(leftBorder, 0);
return true;
} else if (getScrollX() + getWidth() + scrolledX > rightBorder) {
scrollTo(rightBorder - getWidth(), 0);
return true;
}
scrollBy(scrolledX, 0);
mXLastMove = mXMove;
break;
case MotionEvent.ACTION_UP:
// 当手指抬起时,根据当前的滚动值来判定应该滚动到哪个子控件的界面
int targetIndex = (getScrollX() + getWidth() / 2) / getWidth();
int dx = targetIndex * getWidth() - getScrollX();
// 第二步,调用startScroll()方法来初始化滚动数据并刷新界面
mScroller.startScroll(getScrollX(), 0, dx, 0);
invalidate();
break;
}
return super.onTouchEvent(event);
}

@Override
public void computeScroll() {
// 第三步,重写computeScroll()方法,并在其内部完成平滑滚动的逻辑
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
invalidate();
}
}
}


四、View的滑动深入

在Android设备上。滑动几乎是所用应用的标配。我们可以通过不同的滑动和外加的特效效果实现多种多样的自定义控件。从目前来看,常见的滑动方式有以下几种:

1.scrollrTo/scrollBy

关于这两个方法的解释,在郭霖大神的博客中也有介绍(上翻有传送门)。我我们来看看这两个方法的源码:

/**
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}

/**
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}


从源码可以看出,scrollBy实际上调用的也是scrollTo方法,但不同的是,scrollTo方法是让View相对于初始的位置滚动某段距离,实现的是基于所传递的参数的绝对滑动。而scrollBy方法则是让View相对于当前的位置滚动某段距离,实现的是基于当前位置的相对滑动。而这里的控制因素,这是mScrollX和mScrollY这两个参数。在滑动过程中,mScrollX的值总是等于View左边和View的内容的左边缘在水平方向的距离。而mScrollY的值总是等于View上边缘和View的内容的上边缘在垂直方向的距离。

2.动画滑动

在Android3.0之后,我们可以通过使用动画的方式使控件发生平移,从而形成视觉上的滑动效果,另外,使用动画可以和Scroller一般实现弹性动画效果。但是使用动画滑动的话,会产生一些比较严重问题:

1.view动画操作的是控件的影响而不是view的位置参数,意即尽管我们在视觉上看到了滑动的效果,但实际上view的位置却不曾发生改变。这点可以从如果我们不设置view的控件参数fillAftrer为true的时候,那么当动画完成后,View会瞬间恢复到动画前的效果就可以看得出来。而且,即便我们设置了fillAfter参数为true。也只是相当于把view投影到移动的位置,但当我们再要执行点击操作的时候,却是不能发生响应的。因为view的位置不会发生改变。它的真身仍在原始位置上。
2.我们可以使用属性动画来解决以上的问题,但随之而来的问题却是Android3.0以下无法使用属性动画,所以也要解决系统的兼容问题。


3.改变布局参数

通过改变布局参数的方式来实现滑动,实际上改变的是LayoutParams参数,如果我们想要滑动某个控件,则直接通过修改LayoutParams参数来实现,这个方法最为简单暴力,但操作较为复杂,需要根据不同的情况去做不同的处理。使用方法如下(以移动一个Button为例):

Button button = (Button) findViewById(R.id.btn_changeparams);
MarginLayoutParams params = (MarginLayoutParams) button.getLayoutParams();
params.width += 100;
params.leftMargin +=100;
button.requestLayout();


4.使用延时策略实现弹性滑动

前面讲了Scroller和使用动画两种方式可以实现弹性滑动。接下来使用的是延时策略,即通过发送一系列的延时消息来操作view。从而达到一种渐进式的滑动效果。其思想如下(以handle每个1秒发送一个消息为例):

private Handler = new Handler(){
public void handleMwssage(Message msg){
switch(msg.what){
case  MOVE_VIEW:
//move view step
handle.sendEmptyMessageDelayed(MOVE_VIEW,1000);
break;
}
}

};


五、view的事件分发机制(核心知识)

我们从Activity的结构就可以知道,一个在手机屏幕上锁展现出来的Activity,其实是由很多层View对象组合而成的。那么,当我们对这些View对象进行点击操作时,到底是哪个View对象去响应我们的操作呢?这里就涉及到了我们的事件分发机制,同时也是一个非常容易让人困惑的知识点。

1.基础部分:点击事件的传递规则(以MotionEvent为探讨对象)

所谓点击事件的事件分发,其实就是对MotionEvent的分发过程。当一个MotionEvent产生之后,系统需要将其传递给某个具体的View,比如Button控件,并且被这个View所消耗。整个事件分发过程由三个方法完成,分别是:

1.public boolean dispatchTouchEvent(MotionEvent event):
这个方法用来进行事件的分发,当MotionEvent事件传递到当前View时,便会触发当前View的这个方法,返回的结果受当前View的onTouchEvent和下级的dispatchTouchEvent方法的影响,表示是否消耗该MotionEvent。true表示被当前View所消耗,false则表示事件未被消耗。
2.public boolean onInterceptTouchEvent(MotionEvent event);
这个方法在dispatchTouchEvent方法内部调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,此方法不会再被调用,返回结果表示是否拦截当前事件。
3.public boolean onTouchEvent(MotionEvent event);
这个方法在dispatchTouchEvent方法内部调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一事件序列中,当前View无法再次接收到该事件。


以上三者的关系可以用伪代码进行表示:

public boolean dispatchTouchEvent(MotionEvent event){
boolean consume = false;
if(onInterceptTouchEvent(event)){
consume = onTouchEvent(event);
}else{
consume = childView.dispatchTouchEvent(event);
}
return consume;
}


对于一个根ViewGroup来说,当产生点击事件后,首先会传递给它,此时调用它的dispatchTouchEvent方法,如果dispatchTouchEvent方法中的onInterceptTouchEvent(event)返回true,则表示这个ViewGroup要消耗当前事件,于是调用OnTouchEvent(event)方法。而如果onInterceptTouchEvent(event)返回的是false,则将该event交给这个当前View的子元素去处理。如此递归,直到事件被最终处理掉。

当一个点击事件产生后,它的传递顺序如下

Activity -> Window -> View


当顶级View接收到该事件后,就会将其按照事件分发机制去分发该事件,也即从父容器到子容器间层层传递,直到在某一个阶段事件被消耗完毕。但在这里存在另一个问题:如果最底层的子元素并没有消耗点击事件,怎么办?为解决这个问题,系统做了以下的措施:如果一个View的onTouchEvent方法返回的是false,那么该view的父容器的onTouchEvent方法也会被调用,以此类推,若该点击事件没有任何元素去消耗,那么最终仍是会由Activity进行处理这就好比:大Boss让你去解决一个问题,你让你手下的一个工仔去弄,但是工仔看了问题之后,跟你汇报说领导,这个问题我水平有限,解决不了呀。怎么办?那你就看这个问题你能不能解决,如果你可以解决,就直接将它搞定,然后跟大Boss说好了,已经解决。可是你也搞不定呢?那就只好跟大Boss说: 领导,这个问题我水平有限,解决不了呀。大Boss听到你这么说了,那也没办法了,要么就他自己来解决这个问题,要么就直接放弃去解决这个问题了。但不过他要怎么做,都和你、和工仔不会有什么关系了。

关于事件传递的机制,有以下结论:

1.同一个事件序列是指从手指接触到屏幕的那一刻起,到手指离开屏幕的那一刻结束。期间以Down为开始,中间含有数量不等(可以为0)的MOVE,最终则以UP结束。

2.正常情况下,一个事件序列只能被一个View拦截且进行消耗。

3.某个View一旦决定拦截事件序列,那么这一个事件序列只能由它来处理(只要在这个view进行拦截之前没有其他view对这个事件序列进行拦截),并且它的onInterceptTouchEvent方法也不会再被调用。

4.某个View一旦开始处理事件序列,如果它不消耗ACTION_DOWN事件(OnTouchEvent返回false),那么同一个事件序列中的其他事件都不会由它来处理,而是直接将其交由父元素去处理。并且当前view是无法再次接收到该事件的。

5.如果View不消耗除了ACTION_DOWN之外的其他事件,那么这个点击事件就会消失,并且父元素的OnTouchEvent方法也不会被调用,同时,当前View可以持续收到后续的事件,最终这些消失的点击事件会交由Activity进行处理。

6.ViewGroup不拦截任何事件。Android源码中ViewGroup的onInterceptTouchEvent方法默认返回false。

7.在Android源码中,View并没有onInterceptTouchEvent方法,一旦有点击事件传递给它。那么它的OnTouchEvent方法就会被调用。

8.view的OnTouchEvent默认会消耗该事件(默认返回true),除非它是不可点击的(clickable和longclickable同时为false)。

9.view的enable属性不影响onTouchEvent的默认放回值。即便该view是disable状态的,但只要它的clickable或longClickable有一个为true,那么它的返回值就为true。

10.onclick会发生的前提是当前View是可点击的,并且它接收到了ACTION_DOWN和ACTION_UP事件。

11.事件传递过程是由外向内的,及事件总是先传递给父元素。然后再有父元素去分发给子元素。但通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的分发过程,但ACTION_DOWN事件除外。


2.从源码去看事件分发机制:

从上面我们知道,每个MotionEvent都是最先交由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 (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}


我们可以看到,在Activity中判定了一个是咧ACTION_DOWN。这是因为它是事件序列的起点,如果没有这个事件,则后面的一切操作都没有意义。那么这里的onUserInteraction();方法是什么呢?

我们来看看源码:

public void onUserInteraction() {
}


你看,这是个空方法,主要用于实现这个方法来管理一些notification。但这和我们的事件分发没多大关系,这里不多做描述。我们再来看看往后的流程,发现Activity开始把这个事件序列交给Window进行分发,如果返回的是true,则表示事件已经被消耗。否则就调用 onTouchEvent(ev)方法处理该事件。

那么,Activity把事件分发给Window之后,又是怎么处理的呢?从第一章的笔记中我们就可以知道,Window其实是个抽象类,而Window中的superDispatchTouchEvent方法也是个抽象方法,因此我们必须找到Window的实现类->PhoneWindow。我们查看一下PhoneWindow的源码,发现以下方法:

public boolean superDispatchTouchEvent(MotionEvent event){
return mDecor.superDispatchTouchEvent(event);
}


这里的mDecor其实就是DecorView,那么DecorView是什么呢?我们来看

private final class DecorView extends FrameLayout implements RootViewSurfaceTacker{
private DecorView mDecor;
@override
public final View getDecorView(){
if(mDecor == null){
installDecor();
}
return mDecor;
}
}


我们知道,通过

(ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content).getChildAt(0);


这种方式可以获取Activity的所预设置的View,而这个mDector显然就是返回的对象。也就是说,这里的DecorView是顶级View(ViewGroup),内部有titlebar和contentParent两个子元素,contentParent的id是content,而我们设置的main.xml布局则是contentParent里面的一个子元素。那么,当事件传递到DecorView这里的时候,因为DecorView继承了FrameLayout且还是父View,所以最终的事件会传送到View中。

那么,现在事件已经传递到顶级View(一个ViewGroup)了,接下来又该是怎样的呢?逻辑思路如下:

顶级View调用dispatchTouchEvent方法
if 顶级view需要拦截事件
onInterceptTouchEvent方法返回true,
处理点击事件
else
把事件传递给子元素进行处理


根据这个,我们先来看一下ViewGroup对点击事件的分发过程,其主要体现在dispatchTouchEvent方法中。因为这个方法比较长,分段说明,先看下面一段:

// 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会在两种情况下判断是否拦截当前事件:一是事件类型为ACTION_DOWN,二则是mFirstTouchTarget != null。在这里,mFirstTouchTarget是什么意思呢? 可以这么理解:当事件由ViewGroup的子元素成功处理时,mFirstTouchTarget会被赋值并指向子元素。也就是说,当ViewGroup不拦截事件并且把事件交给子元素处理时,则mFirstTouchTarget != null。反之,如果ViewFroup拦截了这个事件,则ViewGroup将不会调用onInterceptTouchEvent(ev)方法,并且同一序列中的其他事件会交由它处理。

当然,事实无绝对,此处有一个特殊情况,就是FLAG _DISALLOW _INTERCEPT这个标志位,它是通过requestDisallowInterceptTouchEvent方法来设置的,一般用于子View中。它一旦被设置,ViewGroup则将无法拦截除了ACTION _DOWN意外的其他点击事件。为什么呢?

再看另一段源码:

// 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();
}


在这段源码中,ViewGroup会在ACTION_DOWN事件到来时做重置状态的操作,而在 resetTouchState方法中会对FLAG _DISALLOW _INTERCEPT进行重置,因此子View调用requestDisallowInterceptTouchEvent方法时并不能影响ViewGroup对ACTION _DOWN的影响。

接着我们再看当ViewGroup不拦截事件的时候。事件会向下分发,交由它的子View进行处理的过程:

// Find a child that can receive the event.
// Scan children from front to back.
final View[] children = mChildren;
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);

final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = customOrder ?
getChildDrawingOrder(childrenCount, i) : i;
final View child = children[childIndex];
if (!canViewReceivePointerEvents(child)  || !isTransformedTouchPointInView(x, y, child, null)) {
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();
mLastTouchDownIndex = childIndex;
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}


从源码中,我们可以发现它的过程如下:首先遍历ViewGroup的所有子元素,然后判定子元素是否能够接收到点击事件(子元素是否在播动画或者点击事件的坐标是否落在子元素的区域内)。如果某个子元素满足这两个条件,那么事件就会交由它来处理。可以看到,dispatchTransformedTouchEvent方法实际上调用的就是子元素的dispatchTouchEvent方法。怎么看的呢?在这个方法的内部,有这么一段:

if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}


返回上一段源码,如果子元素的dispatchTouchEvent(event)方法返回true,那么我们就不需考虑事件在子元素是怎么派发的,那么mFirstTouchTarget就会被赋值,同时跳出for循环。从源码中抽取相关部分见下:

newTouchTarget = addTouchTarget(child, idBitsToAssign);

alreadyDispatchedToNewTouchTarget = true;
break;


有人说,这段代码并没有对mFirstTouchTarget的赋值,因为它实际上出现在addTouchTarget方法中,源码如下:

/**
* Adds a touch target for specified child to the beginning of the list.
* Assumes the target child is not already present.
*/
private TouchTarget addTouchTarget(View child, int pointerIdBits) {
TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}


从这个方法的内部结构可以看出, mFirstTouchTarget是以一种单链表结构,它的赋值与否直接影响到了ViewGroup的拦截策略。

如果遍历所有的子元素事件后都没有被合适地处理,这包含两种情况:一是ViewGroup中没有子元素,二则是子元素处理了点击事件,但是在dispatchTouchEvent方法中返回了false。在这两种情况下,ViewGroup会自己处理点击事件:

// 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);
}


注意这一段源码的第三个参数child 为null,从前面的分析就可以知道,它会调用super.dispatchTouchEvent(event),很显然,这里就从ViewGroup转到了View的dispatchTouchEvent(event)。

值得注意的是,这里说的View是不包含ViewGroup的。因为View类和ViewGroup间其实存在比较大的差异。View类中甚至没有onInterceptTouchEvent方法。也因为如此,View对点击事件的处理办法相对比较简单。我们先来看一下View的dispatchTouchEvent方法源码:

/**
* Pass the touch screen motion event down to the target view, or this
* view if it is the target.
*
* @param event The motion event to be dispatched.
* @return True if the event was handled by the view, false otherwise.
*/
public boolean dispatchTouchEvent(MotionEvent event) {
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(event, 0);
}

if (onFilterTouchEventForSecurity(event)) {
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
return true;
}

if (onTouchEvent(event)) {
return true;
}
}

if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
}
return false;
}


View对点击事件的处理过程比较简单,因为View是一个单独的元素,因此无法向下传递事件。所以它只能自己处理事件。从上面的源码可以看出View对点击事件的处理过程:首先判断有没有设置onTouchListener,如果OnTouchListener中的onTouch方法返回true,那么onTouchEvent就不会被调用,由此可见OnTouchListener方法的优先级高于onTouchEvent。接下来,分析onTouchEvent的实现。先看当View处于不可用状态下点击事件的处理过程:

if ((viewFlags & ENABLED_MASK) == DISABLED) {

if (event.getAction() == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {

setPressed(false);

}

// A disabled view that is clickable still consumes the touch

// events, it just doesn’t respond to them.

return (((viewFlags & CLICKABLE) == CLICKABLE ||

(viewFlags & LONG _CLICKABLE) == LONG _CLICKABLE));

}

很显然,不可用状态下的view照样会消耗点击事件,尽管它看起来不可用。

接着,如果view设置了代理,那么还会执行TouchDelegate的onTouchEvent方法:

if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}


再来看一下onTouchEvent方法中对点击事件的具体处理:

if (((viewFlags & CLICKABLE) == CLICKABLE ||(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) {
switch (event.getAction()) {
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);
}

if (!mHasPerformedLongPress) {
// 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();
}
}
}

if (mUnsetPressedState == null) {
mUnsetPressedState = new UnsetPressedState();
}

if (prepressed) {
postDelayed(mUnsetPressedState,                                 ViewConfiguration.getPressedStateDuration());
} else if (!post(mUnsetPressedState)) {
// If the post failed, unpress right now
mUnsetPressedState.run();
}
removeTapCallback();
}
break;

case MotionEvent.ACTION_DOWN:
mHasPerformedLongPress = false;

if (performButtonActionOnTouchDown(event)) {
break;
}

// Walk up the hierarchy to determine if we're inside a scrolling container.
boolean isInScrollingContainer = isInScrollingContainer();

// For views inside a scrolling container, delay the pressed feedback for
// a short period in case this is a scroll.
if (isInScrollingContainer) {
mPrivateFlags |= PFLAG_PREPRESSED;
if (mPendingCheckForTap == null) {
mPendingCheckForTap = new CheckForTap();
}
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
} else {
// Not inside a scrolling container, so show the feedback right away
setPressed(true);
checkForLongClick(0);
}
break;

case MotionEvent.ACTION_CANCEL:
setPressed(false);
removeTapCallback();
removeLongPressCallback();
break;

case MotionEvent.ACTION_MOVE:
final int x = (int) event.getX();
final int y = (int) event.getY();

// Be lenient about moving outside of buttons
if (!pointInView(x, y, mTouchSlop)) {
// Outside button
removeTapCallback();
if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
// Remove any future long press/tap checks
removeLongPressCallback();

setPressed(false);
}
}
break;
}
return true;
}


从源码来看,只要View的CLICKABLE和LONG_CLICKABLE有一个为true,那么它就将消耗这个事件,即onTouchEvent返回true。不管它是不是DISABLE状态。而当MOTION _UP事件发生时,则触发performClick方法,如果View设置了onClickListener,那么performClick方法内部会调用它的onClick方法:

/**
* Call this view's OnClickListener, if it is defined.  Performs all normal
* actions associated with clicking: reporting accessibility event, playing
* a sound, etc.
*
* @return True there was an assigned OnClickListener that was called, false
*         otherwise is returned.
*/
public boolean performClick() {
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);

ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
li.mOnClickListener.onClick(this);
return true;
}

return false;
}


View的LONG_CLICKABLE属性默认为false,而CLICKABLE的属性则和具体的View有关。通过setClickable和setLongClickable方法可以修改这两个值。此外,在setOnClickListener中也会自动将CLICKABLE属性改为true,而setOnLongClickListener则将LONG _CLICKABLE设置为true。

六、view的滑动冲突

Android中的滑动冲突是比较常见的一个问题,只要在界面中内外两层同时滑动的时候,就会产生滑动。意即有一个占主导地位的View抢着去执行滑动操作,从而带来非常差的用户体验。常见的滑动冲突场景分为如下三种:

场景一:外部滑动方向与内部滑动方向不一致,主要是将ViewPager和Fragment配合使用所形成的页面滑动效果。在这个效果中,可以通过左右滑动来切换页面,而每个页面内部往往又是一个Listview。这种情况下本来是很容易发生滑动冲突的,但ViewPager内部处理了这种滑动冲突,所以如果使用ViewPager,则无需担心这个问题。但如果使用的是Scroller,则必须手动处理滑动冲突了。否则后果就是内外两层只能有一层能够滑动。

处理规则:当用户左右滑动时,需要让外部的View拦截点击事件。当用户上下滑动时,需要让内部View拦截点击事件。这个时候我们就可以根据它们的特征来解决滑动冲突。具体来说是:根据滑动的方向判断到底由什么来拦截事件。

场景二:外部滑动和内部滑动方向一致,比如ScrollView嵌套ListView,或者是ScrollView嵌套自己。表现在要么只能有一层能够滑动,要么两者滑动起来显得十分卡顿。

处理规则:从业务上寻找突破点,比如业务上有规定:当处于某种状态时需要外部View处理用户的操作,而处理另一种状态时则让内部View处理用户的操作。

场景三:上面两种情况的嵌套。

处理规则:同场景二

滑动冲突的解决方式:

针对场景一的滑动冲突,有两种处理滑动的解决方式:

1.外部拦截法:

所谓外部拦截法是指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题,这个方法需要重写父容器的onInterceptTouchEvent方法。伪代码如下所示:

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted=false;
int x=(int)event.getX();
int y=(int)event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted=false;
break;
case MotionEvent.ACTION_MOVE:
if(父容器需要当前点击事件){
intercepted=true;
}else {
intercepted=false;
}
break;
case MotionEvent.ACTION_UP:
intercepted=false;
break;
default:
break;
}
mLastXIntercept=x;
mLastYIntercept=y;
return intercepted;
}


2.内部拦截法:

内部拦截法是指父容器不拦截任何事件,所有的事件传递给子元素,如果子元素需要此事件就直接消耗掉,如果不需要则交由父容器处理。需要配合requestDisallowInterceptTouchEvent方法才能正常工作。伪代码如下:

@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=y-mLastY;
if(父容器需要当前点击事件){
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastX=x;
mLastY=y;
return super.dispatchTouchEvent(event);
}


另外,为了使父容器不接收ACTION_DOWN事件,我们需要对父类进行一下修改:

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action=event.getAction();
if (action==MotionEvent.ACTION_DOWN){
return false;
}else{
return true;
}
}


以上两种方式,是针对场景一而得出的通用的解决方法。对于场景二和场景三而言,只需改变相关的滑动规则的逻辑即可。

注意:因为内部拦截法的操作较为复杂,因此推荐采用外部拦截法来处理常见的滑动冲突。

总结:

这一章是看得最为痛苦的一章,因为之前对于View的认识不足,很多东西看着都显得很陌生,于是不得不先停止这本书,专注于去看其他的博客或者书籍,同时也手写了一些demo加强记忆。如此也隔了许久才敢再捧起这本书。但做好了准备再看书的时候,就会发现自己不再是门外汉了,甚至在做准备的时候存在的一些疑问也在这章中找到了答案。最重要的是,跟着这本书,把View和ViewGroup的相关源码过了一遍之后。就对View的事件分发机制有了一个更加深入的认识。经典好书,感谢大神。下一章,开启。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息