Android View 从测量布局到触摸反馈
自定义View
在Android开发中,自定义 View 最关键的有三个点:绘制、布局和触摸反馈(绘制内容多而简单,查看手册即用即学,这里不记录了)
布局
- 测量阶段 :从上到下
递归
地调用每个View
或者ViewGroup
的measure()
方法,测量他们的尺寸并计算它们的位置- 布局阶段:从上到下
递归
地调用每个View
或者ViewGroup
的layout()
方法,把测得的它们的尺寸和位置赋值给它们
测量阶段
-
measure()
方法被父View
调用,在measure()
中做一些准备和优化工作后,调用onMeasure()
来进行实际的自我测量
- View:
View
在onMeasure()
中会计算出自己的尺寸然后保存- ViewGroup:
ViewGroup
在onMeasure()
中会调用所有子 View
的measure()
让它们进行自我测量,并根据子 View
计算出的期望尺寸来计算出它们的实际尺寸和位置然后保存。同时,它也会根据子 View
的尺寸
和位置
来计算出自己的尺寸然后保存
注:保存会调用setMeasuredDimension(int,int)
,可通过getMeasuredWidth()
和getMeasuredHeight()
获取保存的值
布局阶段
-
layout()
方法被父View
调用,在layout()
中它会保存父View
传进来的自己的位置和尺寸,并且调用onLayout()
来进行实际的内部布局
- View:由于没有子 View,所以
View
的onLayout()
什么也不做- ViewGroup:
ViewGroup
在onLayout()
中会调用自己的所有子 View
的layout()
方法,把它们的尺寸和位置传给它们(layout方法中会有参数,即实际让子View布局的尺寸参数),让它们完成自我的内部布局
下面给出示意图:
View布局过程.png
ViewGroup布局过程.png
了解上面的测量布局过程之后,我们很容易地想到以下3种自定义布局过程
自定义布局过程
- 重写
onMeasure()
来修改已有的View
的尺寸(先调用super.onMeasure()
) - 重写
onMeasure()
来全新定制自定义View
的尺寸(不用super.onMeasure()
) - 重写
onMeasure()
和onLayout()
来全新定制自定义ViewGroup
的内部布局
第一种(修改已有尺寸)
1.重写 onMeasure()
方法,并在里面调用 super.onMeasure(),触发原有的自我测量
2.super.onMeasure()
的下面用getMeasuredWidth()
和 getMeasuredHeight()
来获取到之前的测量结果(宽和高),并加上自己的代码,根据测量结果计算出新的结果
getMeasuredWidth()
和getMeasuredHeight()
是测得的尺寸(即View
在onMeasure
中调用setMeasureDimension()
保存下来的数据)未必与之后父View
调用layout()
时传递进来的的尺寸参数相等,具体值是由父View决定!
3.使用setMeasureDimension()
保存自定义测量的尺寸值
第二种(完全自己计算尺寸)
1.重写onMeasure()
,计算尺寸(自己计算图、文字等等的长宽作加法运算)
2.把计算结果用resolveSize()
修正一下
3.使用setMeasureDimension()
保存自定义测量的尺寸值(也可以自己实现方法来满足父View的限制)
在
onMeasure()
中有两个参数widthMeasureSpec
和heightMeasureSpec
,它们是父View
对子View
的测量尺寸的限制,来源于xml
中以layout_
打头的属性参数,这两个属性各自可以被MeasureSpec.getMode
和MeasureSpec.getSize
拆分为Mode
和SIZE
Mode
是限制的类型,包含3种:无限制UNSPECIFIED
、限制上限AT_MOST
、限制固定值EXACTLY
View
为我们提供了resolveSize()
方法用来便捷地对应这种限制
第三种(ViewGroup自定义测量以及布局过程)
1.重写onMeasure()
来计算内部布局
- 调用每个子View的measure, 让
子View
自我测量- 根据子View给出的尺寸,得到子View的位置,并保存它们的位置和尺寸
- 根据子View的位置和尺寸计算出自己的尺寸并用setMeasuredDimension()保存
2.重写onLayout()
来摆放子View
- 在
onMeasure()
中,需要根据ViewGroup
自身的 可用空间 结合子View
的layout_
打头的属性去测量每个子View
的尺寸,并且用MeasureSpec.makeMeasureSpec()
压缩成MeasureSpec(子View的可用空间)
并保存 -
layout_
打头的属性:这类属性是子View
提供给父View
测量时用的,在Java代码中可以通过view.getLayoutParam()
获得。全新自定义ViewGroup
时只有layout_width
和layout_height
,开发者可以继续自定义这类属性例如layout_gravity
,在自定义测量过程时将其考虑进去即可 - 可用空间:对于
ViewGroup
本身来说最初的可用空间是onMeasure(int widthMeasureSpec, int heightMeasureSpec)
的参数,而在往子View
分配可用空间时,我们可以自己制定规则,可以将widthMeasureSpec
和heightMeasureSpec
直接作为第一个子View
的 可用空间,也可以自己做一些删减。当第一个子View
的测量完成,继续测量第二个子View
的时候,需要在widthMeasureSpec
或者heightMeasureSpec
基础上将第一个子View
的 已用空间 减去,就得到了第二个子View
的可用空间,以此类推 - 可用空间判断方法(通用方式,有特例):
可用空间判断
首先根据子View
在的xml布局声明的layout_width
和layout_height
(lp.width、lp.height)分两种情况
- MATCH_PARENT:
-
ViewGroup
的限制为EXACTLY
或AT_MOST
:由于子View
依赖父View
,父View
需要告诉子View
其可用宽度,并且ViewGroup
本身可用空间可以确定,所以应当给予子View
的限制属性是一个具体值,mode为EXACTLY
;此处给予子View
的宽度是可用宽度,不管 父View 是AT_MOST
还是EXACTLY
,两种的原则都是这块空间子View
随便用,so子View
的可用空间就是当前ViewGroup的初始可用空间(onMeasure()
传来的widthMeasureSpec
)减去已用空间 -
ViewGroup
的限制为UNSPECIFIED
:子View
依赖父View
,但ViewGroup
本身是UNSPECIFIED
无限制大小的(这个地方说大小不是很合适,可用空间可能更佳),于是无法计算出子View
的可用空间,所以直接将子View
的mode
也写为UNSPECIFIED
, 不限制其可用空间大小。size直接给0,因为在mode
为UNSPECIFIED
情况下size
无意义,实际在高版本Android有意义,这里不做解释
-
- WARP_CONTENT:
-
ViewGroup
的限制为EXACTLY
或AT_MOST
:虽然子View
是warp_content,子 View自我测量,但却不能直接将UNSPECIFIED
给子 View
,因为wrap_content
有个隐藏条件是不超过父View
,so这里给子View的mode
是AT_MOST
来限制它的最大尺寸;由于ViewGroup
的mode
为EXACTLY
或AT_MOST
,我们就可以得到可用空间大小,将其减去已用空间传给子View
的可用空间即可(与match_parent时做法类似) -
ViewGroup
的限制为UNSPECIFIED
:同上,子View
需要自我测量,隐藏条件不超过父View
应当被满足,但由于ViewGroup
的限制为UNSPECIFIED
,无法给出具体的可用空间大小,于是无法满足开发者在xml
中给子View
的wrap_content
属性,无奈只能传入UNSPECIFIED
不对其进行限制,size
依旧是0即可
-
- 指定值(sp、dp):直接给
子View
指定一个值,ViewGroup
什么都不用做,直接将值下发给子View
的可用空间,mode
给EXACTLY
即可
布局过程基本结束,接下来是触摸反馈过程
触摸反馈
触摸反馈的本质就是把一系列的
触摸事件
解读为对应的操作,比如按下、弹起、滑动等等,开发者再根据解读出来的操作进行反馈
对于触摸事件,有两点需要注意
-
触摸事件
不相互独立,它们是成序列
(成组)出现的 - 每组事件由
DOWN
开头,由UP
或CANCEL
结尾
大家都知道,自定义触摸反馈只需要重写View
的onTouchEvent(MotionEvent event)
方法,event中包含了此次触摸事件的事件类型
、坐标
等其他信息,当触摸事件不断被触发,onTouchEvent()
就不断被调用,这是触摸反馈的核心
。对于简单的 自定义触摸反馈,重写这个方法已经够了,但难免我们会遇上新的问题 —— 滑动冲突,只有当我们了解整个事件分发机制,才能够彻底解决滑动冲突。
在Android中,当一个触摸事件产生,MotionEvent
将从 Activity(Window)
——>ViewGroup
(多个)——> View
实际上学习Android的触摸事件分发机制就是学习以下3个组件的事件分发机制
-
Activity
对触摸事件的分发机制 -
ViewGroup
对触摸事件的分发机制 -
View
对触摸事件的分发机制
在Android的事件分发机制中,传递的核心方法有3个:
-
dispatchTouchEvent()
:分发(传递)点击事件,当点击事件能够传递给当前View,该方法就会被调用 -
onInterceptTouchEvent()
:只存在于ViewGroup
中,在dispatchTouchEvent()
内部被调用,判断是否拦截了某个事件 -
onTouchEvent()
:处理点击事件,在dispatchTouchEvent()
内部调用
这三个方法的解释不严谨,目的只是让大家现在有一个关系概念,而不是将每个细节都全理解,之后在源码中会有细节
先上一个粗略的图,大概对事件分发流程有个印象,方便看源码的时候理解
Activity的事件分发机制
当一个触摸事件发生时,事件最先传到 Activity
的 dispatchTouchEvent()
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN)
onUserInteraction();
}
//获取Activity的window对象(实现类PhoneWindow)并调用其方法 `superDispatchTouchEvent()`
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
//当未被处理,直接调用Activity的 `onTouchEvent()` 处理
return onTouchEvent(ev);
}
//空方法,当Activity在栈顶,触摸、按Home、back、menu都会触发该方法
public void onUserInteraction() {
}
//Window
@Override
public boolean superDispatchTouchEvent(MotionEvent event)
// mDecor = 顶层View(DecorView)的实例对象
//DecorView是PhoneWindow的内部类,继承自FrameLayout,所以是一个ViewGroup
return mDecor.superDispatchTouchEvent(event);
}
//DecorView
public boolean superDispatchTouchEvent(MotionEvent event) {
// 调用父类的方法 = ViewGroup的dispatchTouchEvent()
// 即 将事件传递到ViewGroup去处理,详细看ViewGroup的事件分发机制
return super.dispatchTouchEvent(event);
}
public boolean onTouchEvent(MotionEvent event) {
// 当一个点击事件未被Activity下任何一个View接收 / 处理时
// 应用场景:处理发生在Window边界外的触摸事
if (mWindow.shouldCloseOnTouch(this, event)) {
finish();
return true;
}
return false;//即只有在点击事件在Window边界外才会返回true,一般情况都返回false
}
public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
// 主要是对于处理边界外点击事件的判断:是否是DOWN事件,event的坐标是否在边界内等
if (mCloseOnTouchOutside && event.getAction() == MotionEvent.ACTION_DOWN
&& isOutOfBounds(context, event) && peekDecorView() != null) {
return true;
}
return false;
// 返回true:说明事件在边界外,即 消费事件
// 返回false:未消费(默认)
}
流程图 Activity事件分发流程图
红框中是重点!事件从这里下发到子View/View Group!
ViewGroup事件的分发机制
Android 5.0后,ViewGroup.dispatchTouchEvent()的源码发生了变化(更加复杂),但原理相同;
为了便于理解,采用Android 5.0前的版本
public boolean dispatchTouchEvent(MotionEvent ev) {
... // 仅贴出关键代码
// ViewGroup每次事件分发时,都需调用onInterceptTouchEvent()询问是否拦截事件
if (disallowIntercept || !onInterceptTouchEvent(ev)) {
// 判断值1:disallowIntercept = 是否禁用事件拦截的功能(默认是false),可通过调用requestDisallowInterceptTouchEvent()修改
// 判断值2: !onInterceptTouchEvent(ev) = 对onInterceptTouchEvent()返回值取反
// a. 若在onInterceptTouchEvent()中返回false(即不拦截事件),就会让第二个值为true,从而进入到条件判断的内部
// b. 若在onInterceptTouchEvent()中返回true(即拦截事件),就会让第二个值为false,从而跳出了这个条件判断
// c. 关于onInterceptTouchEvent() ->>分析1
ev.setAction(MotionEvent.ACTION_DOWN);
final int scrolledXInt = (int) scrolledXFloat;
final int scrolledYInt = (int) scrolledYFloat;
final View[] children = mChildren;
final int count = mChildrenCount;
// 通过for循环,遍历了当前ViewGroup下的所有子View
for (int i = count - 1; i >= 0; i--) {
final View child = children[i];
if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE
|| child.getAnimation() != null) { //可见或正在执行动画
child.getHitRect(frame);
// 判断当前遍历的View是不是正在点击的View,从而找到当前被点击的View
// 若是,则进入条件判断内部
if (frame.contains(scrolledXInt, scrolledYInt)) {
final float xc = scrolledXFloat - child.mLeft;
final float yc = scrolledYFloat - child.mTop;
ev.setLocation(xc, yc);
child.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
// 条件判断的内部调用了该View的dispatchTouchEvent()
// 即 实现了点击事件从ViewGroup到子View的传递(具体请看下面的View事件分发机制)
if (child.dispatchTouchEvent(ev)) {
mMotionTarget = child;
return true;
// 调用子View的dispatchTouchEvent后是有返回值的
// 若该控件可点击,那么点击时,dispatchTouchEvent的返回值必定是true,因此会导致条件判断成立
// 于是给ViewGroup的dispatchTouchEvent()直接返回了true,即直接跳出
// 即把ViewGroup的点击事件拦截掉
}
}
}
}
}
}
boolean isUpOrCancel = (action == MotionEvent.ACTION_UP) ||
(action == MotionEvent.ACTION_CANCEL);
if (isUpOrCancel) {
//如果是ACTION_UP或者ACTION_CANCEL, 将disallowIntercept设置为默认的false
//假如我们调用了requestDisallowInterceptTouchEvent()方法来设置disallowIntercept为true
//当我们抬起手指或者取消Touch事件的时候要将disallowIntercept重置为false
//所以说上面的disallowIntercept默认在我们每次ACTION_DOWN的时候都是false
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}
final View target = mMotionTarget;
// 若点击的是空白处(即无任何View接收事件) / 拦截事件(手动复写onInterceptTouchEvent(),从而让其返回true)
if (target == null) {
ev.setLocation(xf, yf);
if ((mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) {
ev.setAction(MotionEvent.ACTION_CANCEL);
mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
}
return super.dispatchTouchEvent(ev);
// 调用ViewGroup父类的dispatchTouchEvent(),即View.dispatchTouchEvent()
// 因此会执行ViewGroup的onTouch() ->> onTouchEvent() ->> performClick() ->> onClick(),即自己处理该事件,事件不会往下传递(具体请参考View事件的分发机制中的View.dispatchTouchEvent())
// 此处需与上面区别:子View的dispatchTouchEvent()
}
...
}
public boolean onInterceptTouchEvent(MotionEvent ev) {
//返回true = 拦截,即事件停止往下传递(需手动设置,即复写onInterceptTouchEvent(),从而让其返回true)
//返回false = 不拦截(默认)
return false;
}
到这里为止,你会发现没有任何一个地方消费了(使用了)触摸事件,因为目前为止所有的过程都只是在下发(往下传递MotionEvent),而真正要处理事件,是等到View(真的View,不是ViewGroup)在 dispatchTouchEvent()
中去做操作,在这里才会 真正 让 dispatchTouchEvent()
与 onTouchEvent()
产生交集,接着往下看
View事件的分发机制
public boolean dispatchTouchEvent(MotionEvent event) {
// 只有以下3个条件都为真,dispatchTouchEvent()才返回true;否则执行onTouchEvent()
// 1. mOnTouchListener != null
// 2. (mViewFlags & ENABLED_MASK) == ENABLED
// 3. mOnTouchListener.onTouch(this, event)
if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&
mOnTouchListener.onTouch(this, event)) {
// 假如onTouch返回了true,直接返回True
onTouchEvent()处理
return true;
}
// 假如onTouch没返回true,交给此View的
return onTouchEvent(event);
}
// 在这里为mOnTouchListener赋值
public void setOnTouchListener(OnTouchListener l) {
// 即只要我们给控件注册了Touch事件,mOnTouchListener就一定被赋值(不为空)
mOnTouchListener = l;
}
/**
* 回调控件注册Touch事件时的onTouch();需手动复写设置,具体如下(以按钮Button为例)
*/
button.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return false;
}
});