View 的事件体系
1 View 基础知识
1.1 View 的位置参数
1.2 MotionEvent
1.3 TouchSlop
1.4 VelocityTracker
1.5 GestureDetector
1.6 Scroller
1.0 什么是View
View 是 Android 中所有控件的基类
1.1 View 的位置参数
View的位置坐标和父容器的关系容易得出 View 的宽高和坐标的关系:
width = right - left
height = bottom - top
如何得到 View 的这四个参数呢?
在 View 的源码中它们对应四个成员变量:
mLeft
mRight
mTop
mBottom
获取方式为:
getLeft()
getRight()
getTop()
getBottom()
从 Android3.0 开始,View增加了额外的几个参数:
x
y
translationX
translationY
其中 x
和 y
是 View 左上角相对于父容器的坐标,而translationX
和 translationY
是 View 左上角相对于父容器的偏移量。
这几个参数都是相对于父容器的坐标,并且 translationX 和 translationY 的默认值是 0,和 View 的四个基本位置参数一样,View 也为它们提供了 get/set 方法。
这几个参数的换算关系如下所示:
x = mLeft + translationX;
y = mTop + translationY;
// sdk版本不同,成员变量可能不存在,但set.get方法存在
getX() = mLeft + getTranslationX();
getY() = mTop + getTranslationY();
View 在平移的过程中,mLeft 和 mTop 表示的是原始左上角的位置信息,其值不会发生改变,此时发生改变的是 x、y、translationX 和 translationY 这四个参数。
1.2 MotionEvent
在手指接触屏幕后所产生的一系列事件中
典型的事件类型有:
- ACTION_DOWN:手指刚接触屏幕
- ACTION_MOVE:手指在屏幕上移动
- ACTION_UP:手指从屏幕上松开的一瞬间
典型的事件序列有:
- DOWN -> UP
- DOWN -> MOVE -> MOVE -> ... -> UP
通过 MotionEvent 对象我们可以得到点击事件发生的 x
和 y
坐标。
两组方法:
getX() / getY()
:返回的是相对于当前 View 左上角的 x 和 y 坐标。
getRaw() / getRawY()
:返回的是相对于手机屏幕左上角的 x 和 y 坐标。
1.3 TouchSlop
TouchSlop 是系统所能识别出的被认为是滑动的最小距离。
换句话说,当手指在屏幕上滑动时,如果两次滑动之间的距离小于这个常量,那么系统就不任务你是在进行滑动操作。
原因很简单:滑动的距离太短,系统不认为它是滑动的。
这是一个常量,和设备有关,不同的设备在这个值可能是不同的。
获取方式:ViewConfiguration.get(context).getScaledTouchSlop()
意义:当我们处理滑动时,可以利用这个常量做一些过滤。比如两次滑动事件的滑动距离小于这个值,我们就可以认为未达到滑动距离的临界值,因此就可以认为它们不是滑动,这样做可以有更好的用户体验。
在 ViewConfiguration 中默认值为 8 dp。
private static final int TOUCH_SLOP = 8;
同时 在 /frameworks/base/core/res/res/values/config.xml 中定义为8dp。
<!-- Base "touch slop" value used by ViewConfiguration as a
movement threshold where scrolling should begin. -->
<dimen name="config_viewConfigurationTouchSlop">8dp</dimen>
1.4 VelocityTracker
速度追踪:用于追踪手指在滑动过程中的速度,包括水平和竖直方向的速度。
使用方式:
public boolean onTouchEvent(MotionEvent e) {
// copy MotionEvent
MotionEvent vtev = MotionEvent.obtain(e);
// 1. 追踪当前事件的速度
VelocityTracker mVelocityTracker = VelocityTracker.obatin();
mVelocityTracker.addMovement(vtev);
// 2. 获取速度前,先计算速度
mVelocityTracker.computeCurrentVelocity(1000);
// 3. 获取速度
float xvel = mVelocityTracker.getXVelocity();
float yvel = mVelocityTracker.getYVelocity();
// 4. 清空
mVelocityTracker.clear();
vtev.recycle();
}
- 在View的 onTouchEvent 方法中追踪当前单击事件的速度
- 计算速度,这里指一段时间内手指所滑过的像素数。方法参数单位是ms。比如将时间间隔设为 1000ms时,在 1s 内,手指在水平方向从左向右滑过100像素,那么水平速度就是 100像素/s。另注意速度可为负数,从右向左滑动时,水平方向速度即为负值。
1.4 GestureDetector
手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。
protected void onFinishInflate() {
// 1. 在适当的位置初始化 或者 onAttachedToWindow 等
mGestureDetector = new GestureDetector(mContext, new SimpleOnGestureListener() {
@Override
public boolean onSingleTapUp(MotionEvent e) {
return false;
}
});
//解决长按屏幕无法托送的现象
mGestureDetector.setIsLongpressEnabled(false);
}
public boolean onTouchEvent(MotionEvent event) {
// Give everything to the gesture detector
// 2. 接管目标 View 的 onTouchEvent 方法
boolean retValue = mGestureDetector.onTouchEvent(event);
return retValue; // or return true;
}
- 创建GestureDetector对象并实现 onGestureListener 接口,根据需要还可以实现 onDoubleTapListener 从而能够监听双击行为。
- 接管目标View的onTouchEvent方法。
SimpleOnGestureListener 同时实现了 OnGestureListener,OnDoubleTapListener,OnContextClickListener 三个接口,可提供使用。
1.5 Scroller
弹性滑动对象,用于实现 View 的弹性滑动。
当使用 View 的 scrollTo / scrollBy 方法来进行滑动时,其过程是瞬间完全的,没有过渡效果的滑动用户体验不好。
而Scroller实现有过渡效果,是在一定的时间间隔内完成的,不是瞬间完成的。
Scroller 本身无法让View弹性滑动,它需要和View的computeScroll方法配合使用才能完成这个功能。
固定代码使用方法:
mScroller = new Scroller(mContext);
// 缓慢滑动到指定位置
private void smoothScrollTo(int dstX, int dstY) {
int scrollX = getScrollX();
int delta = dstX - scrollX;
mScroller.startScroll(scrollX, 0, delta, 0, 1000);
invalidate();
}
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
2 View 的滑动
- View 本身提供的 scrollTo / scrollBy
- 动画施加平移效果
- 改变LayoutParams重新布局
2.1 scrollTo / scrollBy
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();
}
}
}
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
scrollTo:基于所传递参数的绝对滑动。
scrollBy:基于当前位置的相对滑动。
scrollBy 实际上调用了 scrollTo 方法,内部通过 mScrollX
和 mScrollY
来改变规则,这两个属性可以通过 getScrollX()
和 getScrollY
方法分别得到。
说明:在滑动过程中,mScrollX 的值总是等于 View 左边缘 和 View 内容左边缘在水平方向的距离;mScrollY同理。
scrollTo 和 scrollBy 只能改变 View内容的位置,而不能改变 View 在布局中的位置。
2.2 动画
通过平移动画移动View,主要是操作 View 的 translationX 和 translationY 属性。
既可以采用 View动画,也可以采用属性动画。
<translate
android:duration="100"
android:fromXDelta="0"
android:fromYDelta="0"
android:toXDelta="100"
android:toYDelta="100"
/>
Animation animation = AnimationUtils.loadAnimation(this, R.anim.trans);
targetView.startAnimation(animation);
ObjectAnimator.ofFloat(targetView, "translationX", 0, 100).setDuration(100).start();
View动画是对View的映像做操作,并不能真正改变View的位置参数,包括宽和高。如果希望动画后的状态得以保留还必须将fillAfter属性设置为true,否则View会瞬间恢复到初始状态。这导致button点击事件只能在原先位置,而动画后的状态位置无效。
2.3 LayoutParams
改变布局参数,即改变 LayoutParams。
MarginLayoutParams params = button.getLayoutParams();
params.width += 100;
params.leftMargin += 100;
button.requestLayout(); // 或 button.setLayoutParams(params);
2.4 比较
- scrollTo/scrollBy:操作简单,只能对View的内容进行,且注意参数和一般的理解正负相反,不能移动View本身
- 动画:操作简单,主要使用于没有交互的View 和实现复杂的动画效果,不能真正改变View的位置,属性动画可以
- 改变布局参数:操作稍微复杂,适用于有交互的View
3 弹性滑动
3.1 Scroller
工作原理:
当我们构造一个Scroller对象,并且调用它的startScroll方法时,Scroller内部其实什么也没做,它只是保存了我们传递的几个参数。
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;
}
这里的滑动是指View内容的滑动而非View本身位置的改变。
可以看到,仅仅调用startScroll方法是无法让View滑动的,因为它内部并没做滑动相关的事。
滑动依靠 startScroll 方法下面的 invalidate 方法。导致 View 重绘,View 的draw方法又会去调用 computeScroll 方法,computeScroll 方法在 View 中是一个空实现。自己实现 computeScroll 方法才能实现弹性滑动。而computeScroll又会去向Scroller 获取当前的 scrollX 和 scrollY;然后通过 scrollTo方法实现滑动;接着又调用 postInvalidate 方法进行第二次重绘。如此反复。
查看 computeScrollOffset 方法
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
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 的值。和动画中的差值器类似。
总结:Scroller的设计思想,整个过程它对 View 没有丝毫的引用,甚至它内部连计时器都没有。
Scroller 本身并不能实现 View 的滑动,需要配合 View 的 computeScroll 方法才能完成弹性滑动的效果,它不断的让View重绘,而每一次重绘都有一个时间间隔,通过时间间隔Scroller就可以得出View当前的滑动位置,然后同坐scrollTo方法来完成View的滑动。
就这样,View的每一次重绘都会导致View进行小幅度的滑动,而多次的小幅度滑动就组成了弹性滑动。
3.2 动画
动画本身就是一种渐近的过程,因此通过它来实现的滑动天然就有弹性效果。
ObjectAnimator.ofFloat(textView, "translationX", 0, 100)
.setDuration(100)
.start();
上述代码,可以让一个View本身在1000ms内向左移动100像素。
而scrollTo只能移动 View内容。
可以利用动画的特性模仿Scroller效果:
final int startX = 0;
final int deltaX = 100;
ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(1000);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator anim) {
float fraction = anim.getAnimatedFraction();
textView.scrollTo(startX + (int) (deltaX * fraction), 0);
}
});
animator.start();
移动View本身
移动View内容 scrollTo
3.3 使用延时策略
核心思想:通过发送一系列延时消息从而达到一种渐近式的效果。
具体执行:
- Handler
- View的postDelayed方法
- 线程的sleep方法
以 Handler 为例:
private static final int MESSAGE_SCROLL_TO = 1;
private static final int FRAME_COUNT = 30;
private static final int DELAYED_TIME = 33;
private int mCount = 0;
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_SCROLL_TO: {
mCount++;
if (mCount <= FRAME_COUNT) {
float fraction = mCount / (float) FRAME_COUNT;
int scrollX = (int) (fraction * 100);
textView.scrollTo(scrollX, 0);
mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO, DELAYED_TIME);
}
}
default:
break;
}
}
};
4 View 事件分发机制
4.1 点击事件的传递规则
4.2 事件分发的源码解析
4.1 点击事件的传递规则
分析的对象:MotionEvent
所谓点击事件分发,其实就是对MotionEvent事件的分发过程,即当一个MotionEvent产生了以后,系统需要把这个事件传递给一个具体的View,而这个传递的过程就是分发过程。
分发过程由三个重要的方法共同完成:
- dispatchTouchEvent
事件分发的起始方法。如果事件能够传递给当前View,那么此方法一定会被调用。
返回结果 受 当前 View 的 onTouchEvent方法 和 下级View的dispatchTouchEvent方法 的影响,表示是否消耗当前事件。
- onInterceptTouchEvent
在 dispatchTouchEvent 方法内部调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,此方法不会被再次调用,返回结果表示是否拦截当前事件。
- onTouchEvent
在 dispatchTouchEvent 方法内部调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次接收到事件。
上述三个方法的关系的伪代码:
public boolean dispatchTouchEvent(MotionEvent ev){
boolean consume = false;
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
}else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
另:OnTouchListener#onTouch 的返回值。
当一个View需要处理事件时,如果它设置了OnTouchListener,那么OnTouchListener中的onTouch方法会被调用。如果onTouch返回值false,表示没有消费,则这个View的onTouchEvent方法会被调用;否则不会。
结论:给View设置的OnTouchListener,其优先级比onTouchEvent要高。
另:OnClickListener#oClick没有返回值。
在onTouchEvent方法中,如果View设置了OnClickListener,那么onClick会被调用。
结论:平时常用的onClickListener优先级最低,即处于事件传递的尾端。
传递过程顺序:Activity -> Window -> View
假如:一个View的onTouchEvent返回false,那么它的父容器的onTouchEvent就会被调用,依次类推。最终Activity的onTouchEvent方法将会被调用。
相关结论:
4.2 事件分发的源码解析
4.2.1 Activity对点击事件的分发过程
Activity#dispatchTouchEvent -> PhoneWidow#superDispatchTouchEvent -> DecorView#superDispatchTouchEvent -> ViewGroup#dispatchTouchEvent
4.2.2 顶级View对点击事件的分发过程
ViewGroup
public boolean dispatchTouchEvent(MotionEvent ev){
boolean handled = false;
// Handle an initial down
if (actionMasked == MotionEvent.ACTION_DOWN) {
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}
// Check for interception
boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
}else {
intercepted = false;
}
}else {
intercepted = false;
}
for (int i = childrenCount - 1; i >= 0; i--) {
if (child.dispatchTouchEvent(ev) {
// mFirstTouchTarget 是一个单链表结构
mFirstTouchTarget = TouchTarget.obtain(child);
break;
}
}
if (mFirstTouchTarget==null) {
handled = View.dispatchTouchEvent(ev);
}
return handled;
}
View
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
if (mOnTouchListener != null && mOnTouchListener.onTouch(view, event)) {
result = true;
}
if (!result && onTouch(event)) {
result = true;
}
return true;
}
View
public boolean onTouchEvent(MotionEvent event) {
boolean clickable = (viewFlags == CLICKABLE) || (viewFlags == LONG_CLICKABLE);
if (viewFlags == DISABLED) {
// A disabled view that is clickable still consumes the touch event
// it just doesn't resond to them
return clickable;
}
// 类似onTouchListener
if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}
if (clickable) {
switch(event.getAction()){
case MotionEvent.ACTION_UP:
...
preformClick();
...
break;
}
return true;
}
return false;
}
5 View 滑动冲突
5.1 常见的滑动冲突场景:
- 外部滑动方向和内部滑动方向不一致
- 外部滑动方向和内部滑动方向一致
- 上述两种情况嵌套
本质上来说,这三种滑动冲突场景的复杂度是相同的,区别仅仅是滑动策略的不同。
5.2 滑动冲突处理规则
- 滑动的角度、距离差、速度差
- 业务处理规则
5.3 滑动冲突的解决方式
5.3.1 外部拦截法
父容器拦截处理;
重写父容器的 onInterceptTouchEvent 方法,做相应的拦截即可;
伪代码如下:
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 (???) {// true or false
intercepted = true;
}else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
只需要在条件 ???
处进行修改即可,其他均不需要修改并且不能修改。
-
在ACITION_DOWN事件中,必须返回false,不能拦截ACTION_DOWN事件。一旦父容器拦截了ACTION_DOWN,导致mFirstTouchTarget==null,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交由父容器处理,不能传递给子元素。
-
在ACTION_MOVE事件中,这个事件可以根据需求来决定是否拦截,如果父容器需要拦截就返回true,否则返回false;
-
在ACTION_UP事件中,必须返回false,因为ACTION_UP事件本身没有太多意义。考虑一种情况,假设事件交由子元素处理,如果父容器在ACTION_MOVE时返回了true,就会导致子元素无法接收到ACTION_UP事件,这个时候子元素中的 onClick事件就无法触发,父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的事件都会交给它处理,即便在ACTION_UP时返回了false。比如Boss拦截了你一个任务,最近都不会交给你任何任务。
5.3.2 内部拦截法
父容器不拦截任何事件,所有的事件传递给子元素,如果子元素需要此事就直接消费掉,否则交由父容器处理。
这种方法和Android中的事件分发机制不一致,需要配合 requestDisallowInterceptTouchEvent方法才能正常工作。
重写子元素的 dispatchTouchEvent方法
重写父容器的onInterceptTouchEvent方法
伪代码如下:
// 子元素
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch(event.getAction()){
case MotionEvent.ACTION_DOWN:
parent.requestDisableInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int delatY = y - mLastY;
if (???) {
parent.requestDisableInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的???
条件即可,其他不需要改动且不能改动。
除了子元素需要做处理以外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样子元素调用 parent.requestDisableInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。
为什么父容器不能拦截ACTION_DOWN事件呢?那是因为ACTION_DOWN事件并不受FLAG_DISALLOW_INTERCEPT这个标记位控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用了。
父容器的修改如下:
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
}
return true;
}
参考资料
感谢《Android开发艺术探索》文章作者,第3章 View的事件体系