一、View 的基础知识
1.什么是 View
View 是 Android 中所有控件的基类,ViewGroup 继承了 View,代表一组 View
2.关于 View 的位置参数
View 的位置由它的四个顶点决定,分别是:
- top:View 左上顶点相对于父容器的纵坐标,Top = View.getTop()
- left:View 左上顶点相对于父容器的横坐标,Left = View.getLeft()
- right:View 右下顶点相对于父容器的横坐标,Right = View.getRight()
- bottom:View 右下顶点相对于父容器的纵坐标,Bottom = View.getBottom()
从 Android 3.0 开始,View 增加了 x,y,translationX,translationY。x 和 y 是 View 左上角的坐标,而 translationX 和 translationY 是 View 左上角相对于容器的偏移量。他们之间的关系如下:
x = left + translationX
需要注意:
- 这几个参数都是相对于父容器的坐标
- View 在平移过程中,top、left 表示原始左上角的坐标,其值并不会发生改变,发生改变的是 x、y、translationX、translationY
- 在 onCreatr() 方法中无法获取到 View 的坐标参数,因为此时 View 还未开始绘制,可以通过 View.post() 方法来获取 View 的参数
3、MotionEvent 和 TouchSlop
MotionEvent:典型的事件类型有以下几种:
- ACTION_DOWN——手指刚接触屏幕
- ACTION_MOVE——手指在屏幕上移动
- ACTION_UP——手指从屏幕上离开
通过 MotionEvent 对象我们可以得到点击事件发生的 x 和 y 坐标,系统提供了两组方法来获取坐标:
- getX/getY——返回相对于当前 View 左上角的 x 和 y 坐标
- getRawX/getRawY——返回相对于手机屏幕左上角的 x 和 y 坐标
TouchSlop 是系统所能识别出的被认为是滑动的最小距离,可以通过如下方式获取这个值
ViewConfiguration.get(getApplicationContext()).getScaledTouchSlop()
可以在源码中找到这个常量的定义 Android/sdk/platforms/android-29/data/res/values/config.xml
<!-- Base "touch slop" value used by ViewConfiguration as a
movement threshold where scrolling should begin. -->
<dimen name="config_viewConfigurationTouchSlop">8dp</dimen>
4.VelocityTracker、GestureDetector 和 Scroller
VelocityTracker,速度追踪,用于追踪手指在活动过程中的速度(包括水平、竖直),在 onTouch 中使用如下代码可追踪到当前事件的速度:
VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);
velocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();
velocityTracker.clear();
velocityTracker.recycle();
速度的计算通过公式:速度 = (终点位置 - 起点位置) / 时间段
需要注意:
- computeCurrentVelocity 表示时间间隔,单位为毫秒
- 当不需要使用它时,需要调用 clear() 和 recycle() 方法,重置回收内存
GestureDetector,手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。
使用方法:创建一个 GestureDetector 对象并实现 OnGestureListener 接口,根据需要还可以实现 OnDoubleTapListener 来监听双击行为:
GestureDetector mGestureDetector = new GestureDetector(MainActivity.this);
// 解决长按屏幕后无法拖动的现象
mGestureDetector.setIsLongpressEnabled(false);
接着,接管目标 View 的 onTouchEvent 方法,在待监听 View 的 onTouchEvent 方法中添加如下实现:
boolean consume = mGestureDetector.onTouchEvent(event);
return consume;
我们可以通过 setOnDoubleTapListener 给 GestureDetector 添加双击事件的监听
如果只是监听滑动相关的,建议自己在 onTouchEvent 中实现,如果要监听双击行为,那么就使用 GestureDetector。
二、View的 滑动
通过三种方式可以实现 View 的滑动:
1.使用 scrollTo/scrollBy
两者区别:scrollBy 实际上也是调用了 scrollTo 方法,它实现了基于当前位置的相对滑动,而 scrollTo 则实现了基于所传递参数的绝对滑动。
注意:scrollTo 和 scrollBy 只能改变 View 的内容位置而不能改变 View 在布局中的位置。
mScrollX = View 左边缘 - View 内容左边缘,mScrollY 同理,他们的关系如图:
2.使用动画
动画可分为 View 动画(在 xml 中定义)和属性动画,需要注意的是 View 动画并不能改变 View 的实际位置,而属性动画可以,Android 3.0 以下无法使用属性动画。
3.改变布局参数
改变布局参数,即改变 LayoutParams,比如要将一个 View 向右移动 100px
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) View.getLayoutParams();
params.leftMargin += 100;
View.setLayoutParams(params);
这三种方式都能实现 View 的滑动,他们的优缺点总结:
- scrollTo/scrollBy:操作简单,适合对 View 内容的滑动
- 动画:属性动画没有明显缺点,View 动画无法改变 View 本身的属性
- 改变布局参数:操作稍微复杂,适用于有交互的 View
三、弹性滑动
我们还需要知道如何实现 View 的弹性滑动。比如通过 Scroller、动画、Handler#postDelayer 以及 Thread#sleep 等,它们的共同思想:将一次大的滑动分为若干次小的滑动并在一个时间段内完成。
1.使用 Scroller
Scroller 的典型使用方法:
Scroller scroller = new Scroller(mContext); //实例化一个Scroller对象
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方法
}
}
当我们构造一个 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 滑动的,真正实现滑动效果的是 invalidate(),invalidate() 方法会导致 View 重绘,在 View 的 draw 方法中调用 computeScroll() 方法,这个方法在 View 是空实现,因此我们自己实现了 computeScroll() 方法。
View 重绘后会在 draw 中调用 computeScroll() 方法,而 computeScroll() 又会去向 Scroller 获取当前的 scrollX 和 scrollY,然后通过 scrollTo 方法实现滑动;接着调用 postInvalidate() 方法来进行第二次绘制,绘制过程和第一次一样。
其中 computeScrollOffset() 方法的作用:根据时间的流逝来计算出当前 scrollX 和 scrollY 的值。
概括:Scroller 本身并不能实现 View 的滑动,它需要配合 View 的 computeScroll 方法才能完成弹性滑动的效果。
推荐阅读:Android Scroller实现View弹性滑动完全解析
2.通过动画
动画本身就是一种渐进的过程,因此通过它实现的滑动天然酒具有弹性效果,方法:
// 在100ms内使得View从原始位置向右平移100像素
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
3.使用延时策略
使用 Handler 或者 View 的 postDelayed 方法,也可以用使用线程 sleep 方法,来不断的滑动 View。不过这种方式无法精确定时,因为系统消息的调度也是需要时间的。
四、View 的事件分发机制
1、点击事件的传递规则
我们要分析的对象就是 MotionEvent,即点击事件,当一个 MotionEvent 产生后,系统需要把这个事件传递给一个具体的 View,这个传递的过程就是分发过程。
事件分发过程由三个很重要的方法来共同完成:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent
- dispatchTouchEvent,用来进行事件的分发
- onInterceptTouchEvent,用来判断是否拦截某个事件
- onTouchEvent,用来处理点击事件
它们之间的关系可以用如下伪代码表示:
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
2、事件分发源码解析
推荐阅读:Android事件分发机制完全解析,带你从源码的角度彻底理解
五、滑动冲突
1、常见场景
场景1:外部滑动方向和内部滑动方向不一致(ViewPager + Fragment,Fragment 中有 RecyclerView)
场景2:外部滑动方法和内部滑动方向一致(ScrollView + RecyclerView)
场景3:上面两种情况的嵌套
2.、滑动冲突处理规则
对于场景1:当用户左右滑动时,外部 View 拦截点击事件;当用户上下滑动时,内部 View 拦截点击事件。
对于场景2和3:根据具体业务来进行处理
3、滑动冲突的解决方式
方法一:外部拦截法
- 所有点击事件都要先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要就不拦截。外部拦截法需要重新父容器的 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(true); // 为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);
}
除了子元素需要做处理外,父元素要拦截除了 ACTION_DOWN 以外的其他事件,这样当子元素调用 parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。父元素所做的修改如下:
public boolean onInterceptTouchEvent (MotionEvent event) {
int action = event.getAction();
if(action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
内部拦截法要求父容器不能拦截 ACTION_DOWN 的原因:由于该事件并不受 FLAG_DISALLOW_INTERCEPT 标记位控制,所以一旦父容器拦截了 ACTION_DOWN 事件,那么所有的事件都不会传递给子 View,内部拦截法也就失效了。
六、View 工作原理
1、关于 ViewRoot 和 DecorView
- ViewRoot:即 ViewRootImpl,它是连接 WindowManager 和 DecorView 的纽带,ViewRootImpl 通过 WindowManager 将 DecorView 添加到 Window 中
- DecorView:它包含 titlebar 和 contentView,其中 contentView 为我们在 setContentView 中设置的布局,titlebar 是否存在要看具体主题
View 的绘制流程是从 ViewRootImpl 的 performTraversals() 方法开始的具体过程为:
- 它依次调用 performMeasure()、performLayout() 和 performDraw() 三个方法,分别完成顶级View的绘制。
- 其中,performMeasure()会调用 measure() ,measure() 中又调用 onMeasure(),实现对其所有子元素的measure过程,这样就完成了一次 measur e过程
- 接着子元素会重复父容器的measure过程,如此反复至完成整个View树的遍历。layout 和 draw 同理。
推荐阅读:Android View源码解读:浅谈DecorView与ViewRootImpl
2、理解 MeasureSpec
3、View 的工作流程
View 的工作流程指 measure、layout、draw 这三大流程,即测量、布局和绘制。其中 measure 确定 View 的测量宽/高,layout 确定 View 的最终宽/高和四个顶点的位置,而 draw 则将 View 绘制到屏幕上。
a.measure
measure 过程分两种情况,如果是原始 View,通过 measure 方法就完成了测量过程,如果是一个 ViewGroup,除了完成自己的测量过程外,还要调用所有子元素的 measure 方法,各个子元素再去递归这个流程。
- ViewGroup 的 measure 过程:
首先 ViewGroup 是一个抽象类,它没有 onMeasure 方法,因为它无法统一不同布局的测量过程,所以 onMeasure 方法需要它的子类去实现。比如说 LinearLayout、RelativeLayout,它们除了完成自己的 measure 过程外,还会去遍历所以子元素的 measure 方法。
以 LinearLayout 的 onMeasure 方法为例:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mOrientation == VERTICAL) {
measureVertical(widthMeasureSpec, heightMeasureSpec);
} else {
measureHorizontal(widthMeasureSpec, heightMeasureSpec);
}
}
选择竖直布局来查看,即 measureVertical 方法:
...
for (int i = 0; i < count; ++i) {
...
measureChildBeforeLayout(child, i, widthMeasureSpec, 0,
heightMeasureSpec, usedHeight);
...
}
...
可以看出,系统会遍历子元素执行 measureChildBeforeLayout 方法,在这个方法内部又会调用子元素的 measure 方法,这样各个子元素就开始依次进入 measure 过程了。
- View 的 measure 过程
View 的 measure 方法是一个 final 类型的方法,意味着子类不能重写此方法,此方法会调用 View 的 onMeasure 方法:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(
getDefaultSize(getSuggestedMinimumWidth(),widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
widthMeasureSpec 和 heightMeasureSpec 均是这个 View 的属性,它们是由 ViewGroup 传递进来的。
setMeasuredDimension 方法会设置 View 的宽高测量值,让我们看下 getDefaultSize 方法:
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
我们只需要看 AT_MOST 和 EXACTLY 两种情况,可以看到 getDefaultSize 返回的大小其实就是 measureSpec 的 specSize。
至于 UNSPECIFIED 这种情况一般用于系统内部的测量过程,直接给出结论就不分析:如果 View 没有设置背景,那么返回 android:minWidth 这个属性所指定的值;如果 View 设置了背景,则返回 android:minWidth 和背景的最小宽度这两者中的最大值。height 同理。
注意:
1、如果直接继承 View 的自定义控件,且在布局中使用了 wrap_content 这就相当于使用了 match_parent。因为 wrap_content 的 specMode 是 AT_MOST,如上代码所示,它的宽/高等于 specSize,也就等于父容器当前剩余的空间大小。解决办法:重写 onMeasure 方法,在使用 wrap_content 时,给这个 View 指定一个默认的宽/高。
2、View 的measure 过程和 Activity 的生命周期方法不是同步执行的,如果 View 还没测量完毕,那么获得的宽/高就是0。解决办法:使用 Activity/View#WindowFocusChanged 方法、 view.post(runnable) 方法、ViewTreeObserver 回调来获取宽/高。
b.layout 过程
作用:计算 View 的四个顶点位置:Left、Top、Right、Bottom。
-
View 的 layout 流程:
- View 在 layout() 方法中通过 setFrame() 方法确定自己的四个顶点位置
- 然后在 onLayout() 中遍历所有的子元素并调用其 layout() 方法确定子元素的四个顶点位置
- 然后子元素的 onLayout() 方法又会被调用,重复如上步骤,就能确定布局下所有元素的位置
注意:和 onMeasure 方法类似,onLayout 方法的具体实现和具体布局有关,所以 View 和 ViewGroup 均没有实现 onLayout 方法,需要它们的子类去实现。
细节问题:getMeasuredWidth() 和 getWidth() 有什么区别?
- getMeasuredWidth() 获得 View 测量时的宽,形成与 measure 过程
- getWidth() 获得 View 最终的宽,形成于 layout 过程
即两者的赋值时机不同,测量宽的赋值时机稍微早一点。在 View 的默认实现中,View 的测量宽和最终宽是相等的。除非你重写了 View 的 layout 方法,比如:
@Override
public void layout( int l , int t, int r , int b){
super.layout(l,t,r+100,b+100);
}
不过这样设置没有实际意义。
c .draw 过程
- 作用:将 View 绘制到屏幕上面
- View 的 darw 流程:
- 绘制背景,drawBackground
- 绘制自己 ,onDraw
- 绘制 children,dispatchDraw
- 绘制装饰,onDrawForeground
View 绘制过程的传递是通过 dispatchDraw 来实现的,dispatchDraw 会遍历调用所有子元素的 draw 方法,如此 draw 事件就一层层地传递下去了。
细节问题:View.setWillNotDraw()
/**
* 源码分析:setWillNotDraw()
* 定义:View 中的特殊方法
* 作用:设置 WILL_NOT_DRAW 标记位;
* 注:
* a. 该标记位的作用是:当一个View不需要绘制内容时,系统进行相应优化
* b. 默认情况下:View 不启用该标记位(设置为false);ViewGroup 默认启用(设置为true)
*/
public void setWillNotDraw(boolean willNotDraw) {
setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);
}
// 应用场景
// a. setWillNotDraw参数设置为true:当自定义View继承自 ViewGroup 、且本身并不具备任何绘制时,设置为 true 后,系统会进行相应的优化。
// b. setWillNotDraw参数设置为false:当自定义View继承自 ViewGroup 、且需要绘制内容时,那么设置为 false,来关闭 WILL_NOT_DRAW 这个标记位。