正文
一 、View 的事件体系
知识储备
关于 View ,虽然不是四大组件之一,但是在Android 中 View 有着其不可或缺的地位,其为 Android 提供了丰富的控件,View 是所有视图对象的父类,例如 TextView、Button 等控件均是继承自 View 。
View 在 Android 源码中的文件位置:
platform_frameworks_base/core/java/android/view/
View 实现了 Drawable.callback (动画相关) 、KeyEvent.callback (按键相关)、AccessibilityEventSource (交互相关) 的接口,所有 View 可以处理动画、按键、交互相关的事件,在事件分发的过程中,View 有一套成熟的事件分发机制,可以用于解决事件冲突。
View 的子类不仅是控件,例如 LinearLayout、RelativeLayout 等的存在,Linearlayout 继承自 ViewGroup,顾名思义,可以理解为一组 View ,ViewGroup 是一个 abstract 类,继承自 View ,实现了 ViewParent (用户于父视图交互) 和 ViewManager (用于添加、删除、更新子视图到 Activity ) 的接口。
从 View 和 ViewGroup 的关系也可以看出,View 本身也可以是单个控件,也可以是是一个可以由多个控件组成的一组控件,所以在 ViewGroup 中是可以有子 View 的,子 View 同样也是可以拥有 ViewGroup 。
View 的位置参数
在 Android 中,坐标系的原点在左上角,x 轴的方向为从原点向右延伸, y 轴的方向为向下延伸,而 View 的位置一般由四个顶点来决定,分别对应四个属性:top、left、right、bottom ,四个属性的值为相对 View 父容器的位置,是一个相对坐标,top 对应 View 的左上角在父容器坐标系中的 y 坐标,left 为 x 坐标,right、bottom 为 View 右下角在父容器中对应的 x 和 y 坐标。在开发中经常会使用到的 width 和 height 两个属性的计算方式为
width = right -left;
height = bottom - top;
在 Android 的源码中,可以找到 mTop、mLeft、mRight、mBotton 四个由 protected 修饰的变量以及各自对应的 get 方法,同时还有 translationX 和 translatonY 两个参数,表示的为子 View 左上角对应父容器的偏移量,在当子 View 发生位移的时候,translationX 和 translationY 两个参数的值会发生改变,而 mTop 等的值并不会发生改变,表示的依旧是左上角的位置信息,存在如下的换算关系:
x = left + translationX;
y = top + translationY;
MotionEvent 和 TouchSlop
MotionEvent 为手指触摸屏幕所触发的一系列事件,主要为以下三种:
-
ACTION_DOWN 手指触摸到屏幕
-
ACTION_MOVE 手指在屏幕上滑动
-
ACTION_UP 手指离开屏幕的瞬间
通过 MotionEvent 对象提供的方法,可以得到点击事件的 x 和 y 坐标。
getX() \ getY() 获取被点击 View 的 x 坐标和 y 坐标
getRawX() \ getRawY() 获取相对于手机屏幕的 x 和 y 坐标
如何在 View 或 Activity 中对 MotionEvent 事件进行处理:
//在 View 或 Activity 中拦截 touch events,重写 onTouchEvent
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = MotionEventCompat.getActionMasked(event);
switch (action) {
case (MotionEvent.ACTION_DOWN):
//可以获取坐标进行业务处理等操作……
Log.d(TAG,"action down");
return true;
case (MotionEvent.ACTION_MOVE):
...
Log.d(TAG,"action move");
return true;
case (MotionEvent.ACTION_UP):
...
Log.d(TAG,"action up");
return true;
case (MotionEvent.ACTION_CANCEL):
...
Log.d(TAG,"action cancel");
return true;
case (MotionEvent.ACTION_OUTSIDE):
...
Log.d(TAG,"action outside");
return true;
default:
return super.onTouchEvent(event);
}
}
//View 中使用 setOnTouchListener() 监听touch events
View myView = findViewById(R.id.my_view);
myView.setOnTouchListener(new OnTouchListener() {
public boolean onTouch(View v, MotionEvent event) {
// 事件处理操作
return true;
}
});
Touchslop 为系统所识别出的滑动的最小距离,为常量,不同设备中的值会不同。
//获取方式:
ViewConfiguration.get(getContext()).getScaledTouchSlop();
VelocityTracker、GestureDetector 和 Scroller
VelocityTracker ,速度追踪者,Velocity 在物理学中代表着速度的矢量,Speed 代表速率,在这里,Velocity 也是一个矢量,追踪手指在滑动中的速度,包括 X 方向和 Y 方向的速度。
具体使用方法参考
VelocityTracker 官方文档
。
GestureDetector , 手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为,实例化 GestureDetectorCompat 对象,实现 OnGestureListener 接口,根据需要可以实现 OnDoubleTapListener 从而能监听双击行为,可以监听所有的手势,如果只需要监听部分手势可以继承 GestureDetector.SimpleOnGestureListener 类。
Scroller 弹性滑动对象,用于实现 View 的弹性滑动,在 View 滑动的过程中,如果没有中间过渡,那么用户会感觉很突兀,所以滑动是开发必备知识之一,另外滑动也是实现华丽的自定义动画和自定义 View 的基础。在 Android 中通过三种方式可以实现 View 的滑动:
-
通过 View 本身提供的 scrollTo 和 scrollBy 方法实现
-
通过动画给 View 添加平移效果来实现滑动
-
通过改变 View 的 LayoutParams 让 View 重新布局从而实现滑动。
View 的滑动
滑动方式
-
通过 View 本身提供的 scrollTo 和scrollBy 方法。
在 View 中为了满足组件的滑动需求,提供了上述两种方法,调用 View 中 scrollTo 和 scrollBy 方法是将 View 的内容移动,而非移动 View 本身的位置,两个方法的具体实现为:
/**
* 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);
}
从注释中也可以看到用于设置视图的滚动刷新,可以参考 ListView 的实现理解,滑动和更新的是 View 内容的位置,而 View 本身是不会移动。
如果想要移动 View 本身的位置,可以采用 offsetLeftAndRight 方法:
/**
* Offset this view's horizontal location by the specified amount of pixels.
*
* @param offset the number of pixels to offset the view by
*/
public void offsetLeftAndRight(int offset) {
//具体实现
}
/**
-
使用动画
通过动画可以使一个 View 实现平移、旋转等操作,通过动画来移动 View 主要是操作 translationX 和 translationY 属性,动画方案可以采用 View 动画,也可以采用属性动画( Android 3.0 以上)。
属性动画下降一个 View 在 100ms 中从原始位置向右移动 100 个像素:
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
通过补间动画也可以实现类似操作,不做详解。
需要注意的是在使用动画进行操作时,动画只是针对 View 的影像进行操作,是否保留动画后的状态,需要设置 fillafter 属性为 true;由于没有改变 View 本身的属性,所以在交互上需要做对应的处理,在使用属性动画进行类似操作时,由于直接对 translationX/Y 进行操作,不会存在以上问题,但是在 Android 3.0 以下使用属性动画,需要动画兼容库的支持。
-
改变布局参数
相比前两种方式,改变布局参数就简单一些,通过改变 LayoutParams ,如果通过改变参数来实现 View 向右平移 100 个像素的话,那么只需要修改 LayoutParams 中的 marginLeft 参数即可,使用中可以灵活改变布局的参数来实现自己想要的效果。
滑动方式小结
-
scrollTo/scrollBy : 操作简单、适合对 View 内容的滑动
-
动画:操作简单,主要适用于没有交互的 View 和实现复杂的动画效果
-
改变布局参数: 操作稍微复杂,适用于有交互的 View
弹性滑动
由上述滑动方式,可以实现 View 的滑动效果,但是对于用户来说直来直去的滑动未免太过生硬,这时需要对滑动进行处理,实现所谓的“弹性滑动”。
-
使用 Scroller
Scroller 本身并不能滑动,配合 View 的 computeScroll 方法实现弹性滑动的效果,通过不断的让 View 重绘,并且每次重绘都留有一定的时间间隔,在这个时间间隔内 Scroller 可以得出 View 当前的滑动位置,通过 scrollTo 方法来完成 View 的滑动,由此,每次的 View 重绘都会伴随着小幅度的滑动,由少到多,就组成了 View 的弹性滑动,完成了整个滑动的流程。
具体 Scroller 的内部实现可以参考
www.jianshu.com/p/2f90ae05e…
-
使用动画
动画本身就是用来处理交互和组件运动的过程,所以可以通过动画的方案来实现弹性滑动,动画本身很强大,可以实现很多想要的交互风格和效果。
-
使用延时策略
所谓延时策略,其实就是通过发送一系列的延时消息来达到一种渐进的效果,实现方案有 Handler、View 的 postDelayed 方法,或者线程的 sleep 方法。通过接连不断的发送延时消息,从而实现弹性滑动的效果,在 sleep 方案中,可以通过 while 循环来不断的滑动 View 和 Sleep ,从而实现弹性滑动的效果。
View 的事件分发机制
事件传递方法
-
public boolean dispatchTouchEvent(MotionEvent event)
事件分发,将事件传递给子 View (返回值为 false)或者自己处理(返回值为 true)
-
public boolean onInterceptTouchEvent(MotionEvent event)
是否拦截事件,存在于 ViewGroup ,View 中没有该方法
-
public boolean onTouchEvent(MotionEvent event)
处理事件,在 dispatchTouchEvent 方法中调用
事件分发机制主要用到的便是以上三个方法,首先在一个点击事件产生后,传递流程为 Activity =》Window =》View,当 View 接收到事件后,会按照事件分发机制去分发。
在事件的分发过程中,如果仅有一层 View ,那么也没有必要分发,当前 View 决定是否处理即可。但对于一个根 ViewGroup 来说,它存在着许多子 View ,而子 View 中又可能嵌套着更多的子 View ,所以当事件发生时,根 ViewGroup 的 dispatchTouchEvent 将调用,如果该 ViewGroup 的 onInterceptEvent 返回值为 true ,那么拦截当前事件,事件会交给该 ViewGroup 处理,返回值为 false ,则将事件传递给子
View 的 dispatchTouchEvent 事件,重复上述过程,直到事件被处理。
事件处理所调用的方法便是 onTouchEvent ,但若当前 View 设置了 onTouchListener ,那么其优先级要高于 onTouchEvent ,需要看 onTouch 的返回值,如果返回 false,则 onTouchEvent 将会被调用,如果返回值为 true , 则 onTouch 方法不会被调用,如果当前 View 设置了 onClickListener ,由于 onClickListener 在 onTouchEvent 中调用,则 onClickListener
方法在事件分发中处于一个最低的优先级。
在 dispatchTouchEvent 方法的实现中,可以看到:
public boolean dispatchTouchEvent(MotionEvent event) {
...
boolean result = false;
final int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Defensive cleanup for new gesture
stopNestedScroll();
}
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 != null 时,dispatchTouchEvent 将直接返回 true ,从而 onTouchEvent 将不会执行。在 onTouchEvent 的源码中可以看到对点击的各种操作进行了处理,根据按下的不同时长提供了不同的操作,在这里有个小问题,在事件处理中,是否长按和点击不能同时存在呢? 答案是否定的,满足同时存在的条件是在 onLongClickListener 中将返回值设置为 false ,返回 true 则 onClickListener
不会执行。
小结
-
在事件分发的流程中,返回值是几乎每个方法都有的存在,其中返回值为 true 则代表在这个位置事件进行了处理,不需要再向下或者向上传递,返回值为 false ,则说明事件处理的不够好或者未做处理,传递给其他“有能力”处理的组件。
-
ViewGroup 默认不拦截事件,在 ViewGroup 的 onInterceptTouchEvent 方法中默认返回值为 false。
-
View 没有 onIterceptTouchEvent 方法,一旦有事件传递过来,那么其 onTouchEvent 将会被调用
onTouchEvent 默认返回值为 true , 即默认是处理事件
-
事件传递的过程是由外向内的,即事件先传递给父元素,而后由父元素分发给子 View 。这里区别父元素和外部布局的区别,最开始学习中容易将概念混淆。
-
时间原因暂未更深入探究源码实现,具体细节可以参考鸿洋大神文章
Android 事件分发机制源码解析
。
View 的滑动冲突
滑动冲突发生场景
-
外部滑动方向和内部滑动方向不一致
通常出现在类似 ViewPager 与 Fragment 结合的情况下,横向滑动切换页面,而纵向滑动处理 Fragment 的内容,不过在使用的过程中 ViewPager 并没有出现和 ListView 之类组件的滑动冲突,因为在 ViewPager 中处理了这种冲突。但例如 Scroller 和 ListView 等的组合,就必须手动处理滑动冲突了,否则会造成仅有一层可以滑动,另一层无法滑动的情况出现。除此之外,还会有内部左右滑动,外部上下滑动的情况,但都为外部和内部滑动方向不一致的问题。
-
外部滑动方向和内部滑动方向一致
内外两层滑动方向一致,两个滑动组件嵌套的时候回经常遇到类似的情况,系统无法直接判断是滑动的哪一个 ,从而导致只有一层可以正常滑动。
-
场景 1 和 2 的组合
当在应用中存在内层有一个 1 中的冲突,外部有一个 2 的冲突,就会出现两种冲突的结合,但是个人并未经常遇到,其处理办法和上述 1 、 2 类似,分别处理内层和中层,中层和外层的滑动冲突即可。
滑动处理方案
针对上述 1 中的情况,可以采取当用户左右滑动时,让外部控件拦截滑动事件,在用户上下滑动时,让内部控件拦截滑动事件。
针对 2 中的情况,可以结合业务逻辑来处理滑动冲突,在不同的情况和状态下响应不同的滑动操作。
根据上述的解决方案,处理滑动冲突的大致方式为
外部拦截
和
内部拦截
两种办法,具体的实现不做解析,可以参考 《 Android 开发艺术探索 》 P157 解决方式。
事件体系小结
View 的事件体系主要体现 View 的事件的传递和处理机制,以及滑动相关的内容,View 作为与用户交互较多的前台组件,需要对它的整个事件体现掌握才能让程序和交互变得高效。
View 的事件分发逻辑为一个 V 型的事件传递机制,类似于公司的领导、中层和底层开发,在《Android 群英传》中医生给出了很形象的比喻,在此表示感谢。
View 的滑动冲突解决方案主体思想其实就是事件的拦截,苦于实践经历中暂未有太多需要解决滑动冲突的地方,故暂不锁详细探讨。
二、 View 的工作原理
ViewRoot、DecorView 及 MeasureSpec
View 在 Android 的地位不再强调了,在开发中,经常会遇到一些脑洞惊奇的产品或者设计搞出一些特别的显示效果,使用 Android 提供的开发组件不能满足需求,那么就需要自己去实现一些自定义 View ,通过自定义 View 可以实现许多五花八门的效果。为了实现这一目标,需要掌握 View 的底层工作原理,View 工作原理中核心便是测量、布局、绘制三大流程,在进入到核心过程之前,先对下面几个概念做初步的认识:
-
ViewRoot 和 DrecorView
ViewRoot 是 View 绘制流程的开始,ViewRoot 对应源码文件中的 ViewRootImpl 类,在 WindowsManagerGlobal 文件的 addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) 方法中,创建了 ViewRootImpl 对象,将 ViewRootImpl 和 DecorView 相关联。
root = new ViewRootImpl(view.getContext(), display);
...
// view 是 PhoneWindow 的 DecorView
root.setView(view, wparams, panelParentView);
DecorView 作为顶级 View ,一般情况下内部会包含一个竖直方向的 LinearLayout ,根据 Android 不同版本的实现方案,该 LinearLayout 分为上下不同的部分,上部为标题栏,下部为内容区域。在 Activity 中所加载的 View 为内容区域,内容区域的 id 为 content ,通过 setContentView 方法名也可以看出加载的内容。
View 的事件都要先经过 DecorView ,而后在传递给其他 View。
-
MeasureSpec
MeasureSpec 顾名思义,是一个测量说明书之类的东西,MesasureSpec 是一个 32 位 int 值,高两位代表 SpecMode(测量模式),低 30 位代表 SpecSize(测量规格) ,MeasureSpec 在很大程度上决定着一个 View 的尺寸规格,在此过程中父容器会影响 View 的 MeasureSpec 创建过程。在测量过程中,系统会将 View 的 LayoutParams 根据父容器所施加的规则转换成对应的 MesaureSpec ,然后再根据
MeasureSpec 来测量出 View 的宽/高,此时的宽、高是测量宽高,不一定是最后 View 具体的宽高。
MeasureSpec 将 SpecMode 和 SpecSize 打包成了一个 int 值来避免过多的对象内存分配,同时提供了打包和解包的方法。
SpecMode 分为三类
-
UNSPECIFIED 父容器不对 View 有任何限制,要多大给多大,一般用于系统内部。
-
EXACTLY 父容器已经检测出 View 所需要的精确大小,这个时候 View 的最终大小就是 SpecSize 所指定的值。对应 LayoutParams 中的 match_parent 和具体数值这两种模式。
-
父容器指定了一个可用大小的 SpecSize, View 的大小不能大于这个值,具体是什么要看不同 View 的具体实现,对应 LayoutParams 中的 wrap_content。
普通 View 的 MesaureSpec 创建规则
getChildMeasureSpec 方法表格总结:
UNSPECIFIED 多用于系统内部多次 measure 的情形,暂时不关注。
只要提供父容器的 MeasureSpec 和子元素的 LayoutParams ,就可以快速确定出子元素的 LayoutParams,有了 MeasureSpec 可以进一步确定出 子元素测量后的大小。
View 的绘制流程
View 的工作流程主要为 mesasure、layout、draw 三个过程,即测量、布局和绘制。Measure 过程主要是确定 View 测量的宽和高,layout 确定布局的位置,即 View 最终的大小和四个顶点的位置,draw 过程主要是将 View 绘制到屏幕上。
在开发中其实一般的需求基本可以通过原生的控件解决问题,而在某些情况下,例如设计师脑洞大开,思绪在异次元遨游的时候,那边需要自定义 View 了,往往要自己实现测量、布局和绘制的过程,这一切的基础,要清楚 View 的整个绘制流程。