View事件体系

语言: CN / TW / HK

theme: cyanosis


开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第31天,点击查看活动详情

1.什么是View?

View是Android中所有控件的基类,例如Android中的Button,TextView等组件的基类都是它,还有一个ViewGroup,意为控件组,也就是说包含了一组View,Android中的Linarlayout不仅是一个View还是一个ViewGroup,ViewGroup也是继承了View

2.View的位置参数

left,top,right,bottom,源码中获取他们参数的方法分别是,getLeft(),getTop(),getRight(),gitBottom(),Android3.0之后又加入了x,y,translationX,translationY,其中x,y指View左上角的坐标,translationX,translationY是View左上角相对于容器的偏移量,并且默认为0,

3.VelocityTracker

热度追踪,用于追踪手指在滑动过程中的速度,包括水平和垂直方向的速度。 VelocityTracker velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event); 获取当前速度时可采用以下方式。 velocityTracker.computeCurrentVelocity(1000); int xVelocity = (int) velocityTracker.getXVelocity(); int yVelocity = (int) velocityTracker.getYVelocity(); 需要注意的是,获取速度之前必须先计算速度即computeCurrentVelocity方法必须在getXVelocitygetYVelocity方法之前调用;这里的速度是指一段时间内手指划过的像素数。 使用完毕后及的调用clear方法来重置并回收内存。

4.GestureDetector

手势检测,用于辅助检测用户的单击,长按,滑动,双击等行为。 GestureDetector gestureDetector = new GestureDetector(this); //解决长按屏幕后无法拖动得现象 gestureDetector.setIsLongpressEnabled(false);

接着接管View的onTouchEvent,并实现如下代码

return gestureDetector.onTouchEvent(event);

5.View的滑动

  1. scrollTo/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实现了基于传递参数的绝对滑动。

其中mScrollX和mScrollY是View的两个属性,单位均为像素,可以通过getScrollX和getScrollY得到,在滑动过程中,mScrollX的值总是等于View左边缘和View内容左边缘水平方向的距离,mScrollY的值总是等于View上边缘和View内容上边缘垂直方向的距离,View边缘是指View的位置由四个顶点组成,View内容边缘是指View中内容的边缘。scrollBy和scrollTo的滑动中只能改变View内容的位置而不能改变View在布局中的位置。mScrollX与mScrollY的单位为像素并且当View内容从左向右滑动时mScrollX的值为负值,反之为正值,当View内容从上向下滑动时mScrollY的值为负值,反之为正值。

  1. 使用动画

使用动画可以使View进行平移,主要是操作View的translationX和translationY属性

```

```

//使用属性动画,从原始位置向右移动100像素 ObjectAnimator.ofFloat(targetView, "translationX", 0, 100).setDuration(100).start();

View动画并不能真正改变View的位置,只是一个动画而已,如果动画执行完毕后保留移动后的位置还必须添加 android:fillAfter="true",如果是false则会回到原始位置。这就带来一个问题,假设移动的View有点击事件并且View移动后保留在移动后的位置,此时点击事件只能在View原始的地方才能触发,移动后所在的位置时无法触发点击事件的。

  1. 改变布局参数

这个比较简单,修改LayoutParams的参数即可,例如将View向右平移100px,那么只需要将View的LayoutParams中的marginLeft的值增加100px即可。

总结:上面描述了三种滑动方式,那么该怎么选择呢?

(1)如果修改的是View的内容那么选择scrollTo/scrollBy即可;

(2)如果要做一些复杂且没有交互的动画效果那么选择动画即可;

(3)如果有交互并且有滑动效果那么选择改变布局参数即可。

6.弹性滑动

1.使用Scroll

``` Scroller scroller = new Scroller(this);

private void smoothScrollTo(int destX, int destY) { int scrollX = getScrollX(); int deltaX = destX - scrollX; //1000毫秒内滑向destX,效果就是慢慢滑动 mScroller.startScroll(scrollX, 0, deltaX, 0, 1000); invalidate(); }

@Override
public void computeScroll(){
    if(mScroller.computeScrollOffset()){
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

```

上面的代码是Scroller的典型的使用方法,从代码中看出滑动的过程是在mScroller.startScroll这一句,那我们看看startScroll做了什么

/** * Start scrolling by providing a starting point, the distance to travel, * and the duration of the scroll. * * @param startX Starting horizontal scroll offset in pixels. Positive * numbers will scroll the content to the left. * @param startY Starting vertical scroll offset in pixels. Positive numbers * will scroll the content up. * @param dx Horizontal distance to travel. Positive numbers will scroll the * content to the left. * @param dy Vertical distance to travel. Positive numbers will scroll the * content up. * @param duration Duration of the scroll in milliseconds. */ public void startScroll(int startX, int startY, int dx, int dy, int duration) { mMode = SCROLL_MODE; mFinished = false; mDuration = duration; mStartTime = AnimationUtils.currentAnimationTimeMillis(); mStartX = startX; mStartY = startY; mFinalX = startX + dx; mFinalY = startY + dy; mDeltaX = dx; mDeltaY = dy; mDurationReciprocal = 1.0f / (float) mDuration; }

startScroll什么都没有做,那滑动效果又是怎么实现的呢,答案就是mScroller.startScroll下一行的invalidate();

invalidate()会导致View重绘,View的draw方法中又会调用computeScroll,computeScroll是一个空方法需要自己去实现,上面的代码中已经实现。

View在重绘后会在draw方法中调用computeScroll,computeScroll会通过Scroll获取scrollX,scrollY,然后调用scrollTo方法实现滑动;接着调用postInvalidate方法进行第二次重绘,第二次的重绘与第一次的重绘一致,会调用computeScroll,获取scrollX和scrollY,并通过scrollTo实现滑动到新的位置,如此反复就完成了整个滑动过程。

再看一下Scroll中的computeScrollOffset方法

``` /* * Call this when you want to know the new location. If it returns true, * the animation is not yet finished. / public boolean computeScrollOffset() { ...

    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);

    if (timePassed < mDuration) {
        switch (mMode) {
        case SCROLL_MODE:
            final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
            mCurrX = mStartX + Math.round(x * mDeltaX);
            mCurrY = mStartY + Math.round(x * mDeltaY);
            break;
        ...
        }
    }
    else {
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

```

根据时间的流逝的百分比计算得出scrollX和scrollY改变的百分比并计算出当前的值,这个方法中如果最后返回false则表示绘制还未完成,返回true则表示绘制已完成。

总结:Scroll本身是不能实现View的滑动,需要借助View的computeScroll方法才可以完成弹性滑动,它不断的让View重绘并得出每一次重绘需要的时间的间隔,通过这个间隔就可以得到View滑动的位置,知道了滑动位置通过scrollTo来完成View的滑动,View每一次的小幅度滑动组合在一起就组成了Scroll的弹性滑动。


2.通过动画

动画本身就具有弹性动画的效果,这里聊聊用模仿Scroller来实现View的弹性滑动

``` final int startX = 0; final int detalX = 100; ValueAnimator mAnimator = ValueAnimator.ofInt(0, 1).setDuration(1000);

mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float fraction = animation.getAnimatedFraction(); mBinding.btnForth.scrollTo(startX + (int) (detalX * fraction), 0); } }); mAnimator.start(); ```

根据上述代码可以得知,动画本身并没有作用于任何对象,它只是在1000毫秒内完成整个动画过程。利用这个特性就可以在动画的每一帧到来时获取动画完成的比例,然后再根据这个比例来计算出当前View要滑动的距离。这里View滑动的不是View本身而是View的内容。

3.使用延时策略

核心思想是通过发送一系列的延时消息来达到渐近式的效果。可以使用Handler或者View的postDelayed,也可以使用线程的sleep方法,其中对于postDelayed方法可以不断地发送延时消息,然后在消息中进行View滑动,对于sleep可以再while循环中不断地滑动View和sleep就可以实现弹性滑动的效果。

7.View的事件分发机制

事件分发本质上分发的是MotionEvent,当一个MotionEvent产生后,系统需要把它分配给一个具体的View,它由三个方法共同完成。

public boolean dispatchTouchEvent(MotionEvent ev)

用来进行事件的分发,如果事件能够传递给当前View那么该方法一定会被调用,返回结果受当前View的onTouchEvent和下级View的dispatchTouchEvent影响,表示是否消耗当前事件。

public boolean onInterceptTouchEvent(MotionEvent ev)

用来判断是否拦截某个事件,如果当前View拦截了某个事件那么在同一个事件序列中该方法将不会被再次调用,返回结果表示是否拦截了事件。

public boolean onTouchEvent(MotionEvent ev)

在dispatchTouchEvent方法中调用,表示是否消耗事件,如果没有消耗事件那么在同一个事件系列中当前View将不会再收到事件,返回结果表示是否消耗了事件。

上述三个方法可以用如下伪代码来表示

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

结论:

  1. 同一个事件序列是指从手指触摸屏幕开始到手指离开屏幕结束,以down开始,以up结束,中间包含无数的move;
  2. 正常情况下,一个事件序列只能被一个View拦截且消耗。因为一旦一个元素拦截了某些事件,那么剩下的事件都将会交给它来处理,一个事件是不可以交给两个View来同时处理,但是通过特殊手段是可以的,比如一个View将本该自己处理的事件通过onTouchEvent强行传递给其他View处理。
  3. 某个View一旦决定拦截,那么一个事件序列都只能由它来处理,并且它的onInterceptTouchEvent将不会再被调用。
  4. 某个View一旦开始处理事件,如果他不消耗ACTION_DOWN事件,那么后续都不会再交给它来处理,后续事件都会由它的上级去处理。
  5. 如果View不消耗除ACTION_DOWN以外的事件,那么点击事件将会消失,此时父元素的onTouchEvent并不会被调用,并且当前View仍然会收到后续的事件,最后这些事件将交给Activity处理。
  6. ViewGroup默认不拦截任何事件,在源码中onInterceptTouchEvent方法默认返回false。
  7. View没有onInterceptTouchEvent方法,一旦有点击事件传递给它,那么它的onTouchEvent方法就会被调用。
  8. View的OnTouchEvent默认是被消耗的,除非不可点击(clickable和longClickable都是false),longClickable默认返回false,clickable则是要分情况的,例如Button的clickable默认是true,TextView的clickable默认是false。
  9. View的enable属性是不会影响onTouchEvent的返回结果的,只要longClickable和clickable有一个为true那么onTouchEvent就返回true。
  10. onClick会发生的前提是View可点击,并且收到了down和up事件。
  11. 事件的分发是由外向内的,即事件总是先传递给父元素,然后由父元素分发给子View,通过requestDiallowInterceptTouchEvent可以在子元素中干预父元素的事件分发过程,但是ACTION_DOWN除外。

源码分析:

1.Activity对点击事件的分发

点击事件是由MotionEvent来表示,当一个点击操作发生时,先传递给Activity,然后Activity通过dispatchTouchEvent进行事件分发,具体工作是由Activity内部的Window完成,Window会将事件传递给DecroView,DecroView就是系统的底层容器,通过Activity.getWindow().getDeroView()获得。

/** * 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所附属的Window进行分发,如果返回true那么事件循环就结束了,如果返回false则说明事件没有被处理,所有View的onTouchEvent都会返回false,那么Activity的onTouchEvent就会被调用。

那么Window是如何进行事件分发的呢,或者说Window是如何将事件传递给ViewGroup的呢?

/** * Abstract base class for a top-level window look and behavior policy. An * instance of this class should be used as the top-level view added to the * window manager. It provides standard UI policies such as a background, title * area, default key processing, etc. * * <p>The only existing implementation of this abstract class is * android.view.PhoneWindow, which you should instantiate when needing a * Window. */ public abstract class Window { ... /** * Used by custom windows, such as Dialog, to pass the touch screen event * further down the view hierarchy. Application developers should * not need to implement or call this. * */ public abstract boolean superDispatchTouchEvent(MotionEvent event); ... }

查看Window的源码得知Window是一个抽象类,superDispatchTouchEvent也是一个抽象方法,那么Window的实现类是谁呢,看Window类的注释,大意如下:

Window类可以控制顶级View的外观和行为策略,它的唯一实现是android.view.PhoneWindow。那么我们看一下PhoneWindow

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

@Override public final View getDecorView() { if (mDecor == null || mForceDecorInstall) { installDecor(); } return mDecor; } ```

可以看到PhoneWindow直接将事件传递给了DecorView,DecorView又是什么呢

public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks { ... }

DecorView继承了FrameLayout,是父View,我们知道(ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content).getChildAt(0)这种方式可以获取Activity所设置的View,那么上面的mDecorView就是getWindow().getDecorView()返回的View,而通过setContentView设置的View是它的子View,由于DecorView继承的是FrameLayout且是父View,所以最终事件会传递给子View。

结论:点击事件到达顶级View(一般是一个ViewGroup)以后,会调用ViewGroup的dispatchTouchEvent,然后的逻辑是这样的:如果ViewGroup的 onInterceptTouchEvent返回true,那么事件将由ViewGroup来处理,如果设置了mOnTouchListener那么onTouch将会被调用,否则onTouchEvent被调用。也就是说onTouch会屏蔽onTouchEvent。在onTouchEvent中如果设置了onClickListener那么onClick就会执行。如果ViewGroup没有拦截事件那么会传递给这个点击事件链上的子View,这时子View的dispatchTouchEvent将会被调用。到此为止,事件已经从顶级View传递给了下一级View,如此循环就会完成一个完整的事件分发。

2.顶级View对事件的分发过程

ViewGroup中的dispatchTouchEvent是如何分发事件的呢?看一下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在actionMasked == MotionEvent.ACTION_DOWN和mFirstTouchTarget != null两种情况下会判断是否拦截当前事件,ACTION_DOWN很好理解,从后面的代码可以得知mFirstTouchTarget != null的意思是当事件由ViewGroup不拦截事件并将事件交给子元素处理时mFirstTouchTarget != null,反过来一旦事件由当前ViewGroup拦截时那么mFirstTouchTarget != null就不成立。

3.View对点击事件的处理过程

View对点击事件的处理比较简单,这里不包含ViewGroup,因为View是一个单独的元素,它没有子元素因此无法向下传递,所以那他只能自己处理。

先查看View的dispatchTouch源码

``` /* * 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) { ... 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;
}

```

从源码可以看出,首先判断是否设置了onTouchListener,如果onTouchListener中的onTouch方法返回true,那么onTouchEvent就不会被调用,由此可以验证上面所说的onTouclhListener的优先级高于onTouchEvent,这样做的好处是方便在外界处理点击事件。

8.View的滑动冲突

1.常见的滑动冲突场景

  • 场景一:外部滑动方向和内部滑动方向不一致;
  • 场景二:外部滑动方向和内部滑动方向一致;
  • 上面两种场景嵌套。

2.滑动冲突处理规则

  • 场景一:根据滑动方向来判断到底由谁来拦截事件,左右滑动时由外部View来拦截事件,上下滑动时有内部View来拦截事件。如何知道是上下滑动还是左右滑动呢,可以在滑动过程中根据两个点的坐标来确定,通过坐标来判断的话最简单的就是根据滑动的夹角或者根据水平方向或者垂直方向上滑动的距离。
  • 场景二:它无法根据夹角和距离来处理,但是大多数情况下可以根据业务来判断,比如业务上规定,当处于某种状态时需要外部View响应用户的滑动,而处于另外一种状态时则需要内部View来响应View的滑动。
  • 场景三:场景三的处理方式与场景二基本一致,都是要从业务上来解决这个问题。

3.滑动冲突的解决方式

  • 外部拦截法

外部拦截法就是指所有点击事件都先经过父容器的拦截,父容器需要则进行拦截,不需要则交给子容器处理,这样就可以解决滑动冲突的问题,比较符合点击事件的分发机制。外部拦截法需要重写onInterceptTouchEvent然后在内部做相应的处理即可。

@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; break; case MotionEvent.ACTION_MOVE: if (父容器需要当前点击事件) { intercepted = true; } else { intercepted = false; } break; case MotionEvent.ACTION_UP: intercepted = false; break; } mLastXIntercept = 0; mLastYIntercept = 0; return intercepted; }

上面的代码中ACTION_DOWN必须返回false,因为ACTION_DOWN返回true的话就意味着事件被父容器拦截,后续事件ACTION_MOVE和ACTION_UP将不会再传递给子容器处理,ACTION_MOVE要根据父容器的需求来判定是否拦截,ACTION_UP没有什么意义,返回false即可。

如果事件交由子元素处理,父容器ACTION_UP返回了true,这就导致子元素无法收到ACTION_UP事件,子元素的onClick也就无法调用,但是父容器比较特殊,一旦开始拦截一个事件那后续的事件都会交给他来处理,而ACTION_UP作为最后一个事件也必定会传递给父容器,即使父容器的onInterceptTouchEvent方法中的ACTION_UP返回了false。

  • 内部拦截法

内部拦截法是指父容器不拦截任何事件,将事件全部传递给子元素,如果子元素需要该事件那么直接消耗即可,不需要则交给父容器来处理,这种方法需要配合Android中的requestDisallowInterceptTouchEvent方法才能正常工作,伪代码如下。

``` @Override public boolean dispatchTouchEvent(MotionEvent ev) { int x = (int) ev.getX(); int y = (int) ev.getY(); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: parent.requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: int deltaX = x - mLastX; int deltaY = y - mLastY; if (父容器需要此类点击事件) { parent.requestDisallowInterceptTouchEvent(true); } break; case MotionEvent.ACTION_UP:

        break;
    default:
        break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(ev);

} ```

除了子元素需要处理以外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用了parent.requestDisallowInterceptTouchEvent(true)时,父元素才能继续拦截所需的事件。