第三章 View的事件体系
学习清单:
-
View的事件体系
View的位置参数
View的触控参数
View的滑动
-
View的事件分发机制
- 点击事件传递规则
-
View的滑动冲突
产生原因
常见的滑动冲突场景
处理规则
解决方案
简介
在Android的世界中View是所有控件的基类,其中也包括ViewGroup在内,ViewGroup是代表着控件的集合,其中可以包含多个View控件
从某种角度上来讲Android中的控件可以分为两大类:View与ViewGroup。通过ViewGroup,整个界面的控件形成了一个树形结构,上层的控件要负责测量与绘制下层的控件,并传递交互事件
在每棵控件树的顶部都存在着一个ViewParent对象,它是整棵控件树的核心所在,所有的交互管理事件都由它来统一调度和分配,从而对整个视图进行整体控制
一. View 的事件体系
1. View的位置参数
a. Q:如何确定一个View的位置?
A: View的位置主要通过它的四个顶点来决定, 分别是:
top: 左上角纵坐标
left: 左上角横坐标
right: 右下角横坐标
-
bottom: 右下角纵坐标
b. View的宽高和坐标的关系:
width = right - left;
height = bottom - top;
// 获取这四个参数的方法
Left = getLeft();
Right = getRight();
Top = getTop()
Bottom = getBottom();
c. 从Android3.0开始, View增加了额外的四个参数: x, y, translationX 和 translationY, 其中x 和 y 是View左上角坐标, 而translationX 和 translationY 是View左上角相对于父容器的偏移量, 这几个参数也是相对于父容器的坐标, 关系如下图:
- 换算关系: x = left + translationX, y = top + translationY
- X由此可见, x和left不同体现在:left是View的初始坐标, 在绘制完毕后就不会再改变;而x是View偏移后的实时坐标, 是实际坐标. y和top的区别同理
2. View的触控参数
a. MotionEven 和 TouchSlop:
-
MotionEven的触摸事件:
ACTION_DOWN : 手指放接触屏幕
ACTION_MOVE : 手指在屏幕上移动
ACTION_UP : 手指从屏幕上松开的一瞬间
正常情况下, 一次手指触碰屏幕的行为可能触发一系列点击事件, 如:
- 点击屏幕后立刻松开: DOWN -> UP;
- 点击屏幕后一会再松开: DOWN -> MOVE -> ... -> MOVE -> UP;
-
通过MotionEven对象我们可以得到事件发生的 x 和 y 坐标:
getX() / getY(): 返回相对于当前View左上角的 x, y 坐标
getRawX() / getRawY(): 返回相对于手机屏幕左上角的 x , y 坐标
-
TouchSlop的使用:
TouchSlop: 是系统所能识别出的滑动最小距离, 是一个常量, 不同的设备上这个值可能是不同的
-
通过 ViewConfiguration.get(getContext()).getScaledTouchSlop()可以获得这个常量
使用建议:
- 通过TouchSlop, 可以对用户的一些操作进行过滤, 提高用户使用体验
b. VelocityTracker 和 GestureDetector:
-
VelocityTracker速度追踪:
功能: 用于追踪手指在滑动过程中的速度, 包括水平和竖直方向的速度
-
使用:
- 在View的onTouchEvent()方法中追踪当前单击事件的速度
VelocityTracker velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event);
- 获取滑动速度
int xVelocity = (int) velocityTracker.getXVelocity(); int yVelocity = (int) velocityTracker.getYVelocity();
注意:
- 获取速度之前必须调用computerCurrentVelocity方法
- 获取到的速度指的是在规定时间内划过的像素数
- 获取到的速度可以为负数, 从右向左 / 从下向上 获取到的就是负数
- 速度的公式 : 速度 = (终点位置 - 起点位置) / 时间段
- 在不使用VelocityTracker的时候需要调用clear方法来重置并回收内存, recycle方法重新调用
-
GestureDetector手势检测
功能: 用于检测用户的单击, 滑动, 长按, 双击等行为
-
使用:
创建一个GestureDetector对象
-
根据不同的需求实现OnGestureListener接口或OnDoubleTapListener接口
方法名 描述 所属接口 onDown 手指轻触屏幕的一瞬间, 由1个ACTION_DOWN触发 OnGestureListener onShowPress 手指轻触屏幕尚未松开或移动 OnGestureListener onSingleTapUp 手指轻触屏幕后松开, 伴随1个ACTION_UP触发 OnGestureListener onScroll 手指轻触屏幕并拖动 OnGestureListener onLongPress 长按屏幕不放 OnGestureListener onFling 触摸屏幕快速滑动后松开 OnGestureListener onDoubleTap 双击, 不能和onSingleTapConfirmed共存 OnDoubleTapLinstener onSingleTapConfirmed 严格单击行为, 指不能是双击中的一次单击 OnDoubleTapLinstener onDoubleTapEvent 发生了双击行为, 在双击期间移动也会触发 OnDoubleTapLinstener
注意: 在实际开发中, 如果需要监听双击事件, 则使用GestureDetector, 否则可以在View的onTouchEvent方法中实现
3. View的滑动
a. 通过View本身的scrollTo / scrollBy实现:
-
方法:
scrollTo: 基于所传递参数的绝对滑动
scrollBy: 实际上是通过调用scroolTo方法实现, 传递的是偏移量
注意: 通过scrollTo / scrollBy只能改变View的内容, 不能改变View在当前布局中的位置
b. 使用动画
-
使用
xml
文件的方式:- xml代码:
- java代码:
Animation animation = AnimationUtils.loadAnimation(this, R.anim.translate); view.startAnimation(animation);
推荐阅读: Animation补间动画
-
注意: 这种动画只能改变View的内容所在的位置, 真身仍在原来的位置
关于动画的内容, 会在第7章详细说明
c. 改变布局参数
说明: 通过改变LayoutParams来实现, 或例如在Button旁边放置一个View, 通过改变这个View的大小来实现
注意: 在修改了LayoutParams后记得使用
requestLayout()
方法更新
d. 三种方式的优缺点:
scrollTo / scrollBy : 操作简单, 适合对View内容的滑动;
动画: 操作简单, 主要适用于不与用户交互, 复杂的动画效果
改变布局参数: 操作稍微复杂, 但适用与有交互的View
4.View的弹性滑动
View的滑动效果显得太过生硬, Android中还提供了许多弹性滑动的方法, 下面记录一下Android中的弹性滑动
a. 使用Scroller
-
使用:
创建Scroller的实例
调用startScroll()方法来初始化滚动数据并刷新界面
重写computeScroll()方法,并在其内部完成平滑滚动的逻辑
-
惯用代码:
private void smoothScrollTo(int dstX, int dstY) { int scrollX = getScrollX();//View的左边缘到其内容左边缘的距离 int scrollY = getScrollY();//View的上边缘到其内容上边缘的距离 int deltaX = dstX - scrollX;//x方向滑动的位移量 int deltaY = dstY - scrollY;//y方向滑动的位移量 scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000); //开始滑动 invalidate(); //刷新界面 } @Override//计算一段时间间隔内偏移的距离,并返回是否滚动结束的标记 public void computeScroll() { if (scroller.computeScrollOffset()) { scrollTo(scroller.getCurrX(), scroller.getCurY()); postInvalidate();//通过不断的重绘不断的调用computeScroll方法 } }
其中startScroll源码如下,可见它并没有进行实际的滑动操作,而是通过后续invalidate()方法去做滑动动作
public void startScroll(int startX,int startY,int dx,int dy,int duration){ mMode = SCROLL_MODE; mFinished = false; mDuration = duration;//滑动时间 mStartTime = AnimationUtils.currentAminationTimeMills();//开始时间 mStartX = startX;//滑动起点 mStartY = startY;//滑动起点 mFinalX = startX + dx;//滑动终点 mFinalY = startY + dy;//滑动终点 mDeltaX = dx;//滑动距离 mDeltaY = dy;//滑动距离 mDurationReciprocal = 1.0f / (float)mDuration; }
具体过程:在MotionEvent.ACTION_UP事件触发时调用startScroll方法->马上调用invalidate/postInvalidate方法->会请求View重绘,导致View.draw方法被执行->会调用View.computeScroll方法,此方法是空实现,需要自己处理逻辑。具体逻辑是:先判断computeScrollOffset,若为true(表示滚动未结束),则执行scrollTo方法,它会再次调用postInvalidate,如此反复执行,直到返回值为false。如图所示:
- 原理: 原理:Scroll的computeScrollOffset()根据时间的流逝动态计算一小段时间里View滑动的距离,并得到当前View位置,再通过scrollTo继续滑动。即把一次滑动拆分成无数次小距离滑动从而实现弹性滑动。
b. 使用动画:
动画本身就是一种渐近的过程,故可通过动画来实现弹性滑动
-
代码:
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
c. 使用延时策略:
描述: 通过Handler / View的postDelayed方法发送一系列延时消息从而打到一种渐进式的效果, 也可以用线程的sleep方法
注意: 对弹性滑动完成总时间有精确要求的使用场景下, 使用延时策略是一个不太合适的选择
二. View的事件分发机制
事件分发机制是View的核心知识点, 通过学习事件分发机制可以解决滑动冲突难题, 巩固我们对View的掌握
1. 点击事件传递规则
a. 事件分发本质:
就是对MotionEvent事件分发的过程。即当一个MotionEvent产生了以后,系统需要将这个点击事件传递到一个具体的View上
-
传递顺序:
- Activity(Window) -> ViewGroup -> View
补充: 如果所有元素都不处理这个事件, 那么这个事件最终会由Activity处理, 即Activity的onTouchEvent方法会被调用
b. 核心方法:
-
public boolean dispatchTouchEvent(MotionEvent ev):
用于进行事件的分发, 如果事件能传递给当前View, 则此方法一定调用. 返回结果受到当前View的onTouchEvent和下级dispatchTouchEvent影响, 表示是否消耗当前事件
-
public boolean onInterceptTouchEvent(MotionEvent event):
在dispatchTouchEvent方法中调用, 用于判断是否拦截当前事件, 如果当前View拦截了某个事件, 则同一个任务序列中此方法不会再被调用(只有ViewGroup有这个方法)
-
public boolean onTouchEvent(MotionEvent event):
在dispatchTouchEvent方法中调用, 用于处理点击事件, 返回结果表示是否消耗当前事件, 如果不消耗, 则在同一个任务序列中, 当前View无法再次接受到事件
补充阅读: Android事件分发机制(源码)
三. View的滑动冲突
a. 产生原因:
- 一般情况下,在一个界面里存在内外两层可同时滑动的情况时,会出现滑动冲突现象
b. 常见的滑动冲突场景:
外部滑动方向和内部滑动方向不一致;
外部滑动方向和内部滑动方向一致;
上述两种情况的嵌套;
c. 处理规则:
对于场景一: 左右滑动时, 让外部View拦截事件. 上下滑动时, 让内部View拦截事件
对于场景二: 根据相应的业务情景做出相应的操作
对于场景三: 将组合问题根据场景拆分成若干个小问题, 逐一解决
Q: 如何判断是左右滑动还是上下滑动:
- 根据滑动路径与水平方向上的夹角
- 根据水平和竖直方向上的速度差
- 根据水平和竖直方向上的距离差
d. 解决方案:
-
外部拦截法:
含义: 先经过父容器, 如果需要就拦截, 不需要再分发到子View
方法: 重写父容器的onInterceptTouchEvent方法, 在内部做相应的拦截
public boolean onInterceptTouchEvent(MotionEvent event) { boolean intercepted = false; int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { //对于ACTION_DOWN事件必须返回false,一旦拦截后续事件将不能传递给子View case MotionEvent.ACTION_DOWN: intercepted = false; break; //对于ACTION_MOVE事件根据需要决定是否拦截 case MotionEvent.ACTION_MOVE: if (父容器需要当前事件){ intercepted = true; } else{ intercepted = flase; break; } //对于ACTION_UP事件必须返回false,一旦拦截子View的onClick事件将不会触发 case MotionEvent.ACTION_UP: intercepted = false; break; default: break; } mLastXIntercept = x; mLastYIntercept = y; return intercepted; }
-
内部拦截法
含义: 父容器不拦截任何事件, 如果子元素不需要就交由父容器处理
方法: 重写子元素的dispatchTouchEvent方法, 再配合requestDisallowInterceptTouchEvent方法,
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
// parent.requestDisallowInterceptTouchEvent()可以理解为:
// 告诉(request)父容器(parent)
// 不再(disallow)拦截(intercept)触摸事件(touchEvent)吗(boolean)
// 当requestDisallowInterceptTouchEvent(ture)时
// 父容器不再拦截接下来的一系列事件
parent.requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (父容器需要此类点击事件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
父View需要重写onInterceptTouchEvent方法:
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}