手记

转 <android开发>学习笔记

3 View的事件体系

本章介绍View的事件分发和滑动冲突问题的解决方案。

3.1 view的基础知识

View的位置参数、MotionEvent和TouchSlop对象、VelocityTracker、GestureDetector和Scroller对象。

3.1.1什么是view

View是Android中所有控件的基类,View的本身可以是单个空间,也可以是多个控件组成的一组控件,即ViewGroup,ViewGroup继承自View,其内部可以有子View,这样就形成了View树的结构。

3.1.2 View的位置参数

View的位置主要由它的四个顶点来决定,即它的四个属性:top、left、right、bottom,分别表示View左上角的坐标点( top,left) 以及右下角的坐标点( right,bottom) 。
同时,我们可以得到View的大小:

width = right - left
height = bottom - top

而这四个参数可以由以下方式获取:

Left = getLeft();
Right = getRight();
Top = getTop();
Bottom = getBottom();

Android3.0后,View增加了x、y、translationX和translationY这几个参数。其中x和y是View左上角的坐标,而translationX和translationY是View左上角相对于容器的偏移量。他们之间的换算关系如下:

x = left + translationX;
y = top + translationY;

top,left表示原始左上角坐标,而x,y表示变化后的左上角坐标。在View没有平移时,x=left,y=top。View平移的过程中,top和left不会改变,改变的是x、y、translationX和translationY。

3.1.3 MotionEvent和TouchSlop

MotionEvent
事件类型

  • ACTION_DOWN 手指刚接触屏幕

  • ACTION_MOVE 手指在屏幕上移动

  • ACTION_UP 手指从屏幕上松开

点击事件类型

  • 点击屏幕后离开松开,事件序列为DOWN->UP

  • 点击屏幕滑动一会再松开,事件序列为DOWN->MOVE->…->MOVE->UP

通过MotionEven对象我们可以得到事件发生的x和y坐标,我们可以通过getX/getY和getRawX/getRawY得到。它们的区别是:getX/getY返回的是相对于当前View左上角的x和y坐标,getRawX/getRawY返回的是相对于手机屏幕左上角的x和y坐标。

TouchSloup
TouchSloup是系统所能识别出的被认为是滑动的最小距离,这是一个常量,与设备有关,可通过以下方法获得:

ViewConfiguration.get(getContext()).getScaledTouchSloup().

当我们处理滑动时,比如滑动距离小于这个值,我们就可以过滤这个事件(系统会默认过滤),从而有更好的用户体验。

3.1.4 VelocityTracker、GestureDetector和Scroller

VelocityTracker
速度追踪,用于追踪手指在滑动过程中的速度,包括水平放向速度和竖直方向速度。使用方法:

  • 在View的onTouchEvent方法中追踪当前单击事件的速度

      VelocityRracker velocityTracker = VelocityTracker.obtain();
      velocityTracker.addMovement(event);
  • 计算速度,获得水平速度和竖直速度

      velocityTracker.computeCurrentVelocity(1000);
      int xVelocity = (int)velocityTracker.getXVelocity();
      int yVelocity = (int)velocityTracker.getYVelocity();

    注意,获取速度之前必须先计算速度,即调用computeCurrentVelocity方法,这里指的速度是指一段时间内手指滑过的像素数,1000指的是1000毫秒,得到的是1000毫秒内滑过的像素数。速度可正可负:速度 = ( 终点位置 - 起点位置) / 时间段

  • 最后,当不需要使用的时候,需要调用clear()方法重置并回收内存:

      velocityTracker.clear();
      velocityTracker.recycle();

GestureDetector
手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。使用方法:

  • 创建一个GestureDetector对象并实现OnGestureListener接口,根据需要,也可实现OnDoubleTapListener接口从而监听双击行为:

      GestureDetector mGestureDetector = new GestureDetector(this);
      //解决长按屏幕后无法拖动的现象
      mGestureDetector.setIsLongpressEnabled(false);
  • 在目标View的OnTouchEvent方法中添加以下实现:

      boolean consume = mGestureDetector.onTouchEvent(event);
      return consume;
  • 实现OnGestureListener和OnDoubleTapListener接口中的方法
    其中常用的方法有:onSingleTapUp(单击)、onFling(快速滑动)、onScroll(拖动)、onLongPress(长按)和onDoubleTap( 双击)。建议:如果只是监听滑动相关的,可以自己在onTouchEvent中实现,如果要监听双击这种行为,那么就使用GestureDetector。

Scroller
弹性滑动对象,用于实现View的弹性滑动。其本身无法让View弹性滑动,需要和View的computeScroll方法配合使用才能完成这个功能。使用方法:

Scroller scroller = new Scroller(mContext);
//缓慢移动到指定位置
private void smoothScrollTo(int destX,int destY){
    int scrollX = getScrollX();
    int delta = destX - scrollX;
    //1000ms内滑向destX,效果就是慢慢滑动
    mScroller.startScroll(scrollX,0,delta,0,1000);
    invalidata();
} 
@Override
public void computeScroll(){
    if(mScroller.computeScrollOffset()){
    scrollTo(mScroller.getCurrX,mScroller.getCurrY());
    postInvalidate();
    }
}

3.2 View的滑动

三种方式实现View滑动

3.2.1 使用scrollTo/scrollBy

scrollBy实际调用了scrollTo,它实现了基于当前位置的相对滑动,而scrollTo则实现了绝对滑动。

scrollTo和scrollBy只能改变View的内容位置而不能改变View在布局中的位置。滑动偏移量mScrollX和mScrollY的正负与实际滑动方向相反,即从左向右滑动,mScrollX为负值,从上往下滑动mScrollY为负值。

3.2.2 使用动画

使用动画移动View,主要是操作View的translationX和translationY属性,既可以采用传统的View动画,也可以采用属性动画,如果使用属性动画,为了能够兼容3.0以下的版本,需要采用开源动画库nineolddandroids。 如使用属性动画:(View在100ms内向右移动100像素)

    ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();

3.2.3 改变布局属性

通过改变布局属性来移动View,即改变LayoutParams。

3.2.4 各种滑动方式的对比

  • scrollTo/scrollBy:操作简单,适合对View内容的滑动;

  • 动画:操作简单,主要适用于没有交互的View和实现复杂的动画效果;

  • 改变布局参数:操作稍微复杂,适用于有交互的View。

3.3 弹性滑动

3.3.1 使用Scroller

使用Scroller实现弹性滑动的典型使用方法如下:

Scroller scroller = new Scroller(mContext);
//缓慢移动到指定位置
private void smoothScrollTo(int destX,int dextY){
    int scrollX = getScrollX();
    int deltaX = destX - scrollX;
    //1000ms内滑向destX,效果就是缓慢滑动
    mScroller.startSscroll(scrollX,0,deltaX,0,1000);
    invalidate();
} 
@override
public void computeScroll(){
    if(mScroller.computeScrollOffset()){
    scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
    postInvalidate();
    }
}

从上面代码可以知道,我们首先会构造一个Scroller对象,并调用他的startScroll方法,该方法并没有让view实现滑动,只是把参数保存下来,我们来看看startScroll方法的实现就知道了:

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;
}

可以知道,startScroll方法的几个参数的含义,startX和startY表示滑动的起点,dx和dy表示的是滑动的距离,而duration表示的是滑动时间,注意,这里的滑动指的是View内容的滑动,在startScroll方法被调用后,马上调用invalidate方法,这是滑动的开始,invalidate方法会导致View的重绘,在View的draw方法中调用computeScroll方法,computeScroll又会去向Scroller获取当前的scrollX和scrollY;然后通过scrollTo方法实现滑动,接着又调用postInvalidate方法进行第二次重绘,一直循环,直到computeScrollOffset()方法返回值为false才结束整个滑动过程。 我们可以看看computeScrollOffset方法是如何获得当前的scrollX和scrollY的:

public boolean computeScrollOffset(){
    ...
    int timePassed = (int)(AnimationUtils.currentAnimationTimeMills() - mStartTime);
    if(timePassed < mDuration){
        switch(mMode){
        case SCROLL_MODE:
        final float x = mInterpolator.getInterpolation(timePassed * mDuratio
        nReciprocal);
        mCurrX = mStartX + Math.round(x * mDeltaX);
        mCurrY = mStartY + Math.round(y * mDeltaY);
        break;
        ...
        }
    } 
    return true;
}

到这里我们就基本明白了,computeScroll向Scroller获取当前的scrollX和scrollY其实是通过计算时间流逝的百分比来获得的,每一次重绘距滑动起始时间会有一个时间间距,通过这个时间间距Scroller就可以得到View当前的滑动位置,然后就可以通过scrollTo方法来完成View的滑动了。

3.3.2 通过动画

动画本身就是一种渐近的过程,因此通过动画来实现的滑动本身就具有弹性。实现也很简单:

ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start()
;    
//当然,我们也可以利用动画来模仿Scroller实现View弹性滑动的过程:
final int startX = 0;
final int deltaX = 100;
ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000);
animator.addUpdateListener(new AnimatorUpdateListener(){
    @override
    public void onAnimationUpdate(ValueAnimator animator){
    float fraction = animator.getAnimatedFraction();
    mButton1.scrollTo(startX + (int) (deltaX * fraction) , 0);
    }
});
animator.start();

上面的动画本质上是没有作用于任何对象上的,他只是在1000ms内完成了整个动画过程,利用这个特性,我们就可以在动画的每一帧到来时获取动画完成的比例,根据比例计算出View所滑动的距离。采用这种方法也可以实现其他动画效果,我们可以在onAnimationUpdate方法中加入自定义操作。

3.3.3 使用延时策略

延时策略的核心思想是通过发送一系列延时信息从而达到一种渐近式的效果,具体可以通过Hander和View的postDelayed方法,也可以使用线程的sleep方法。 下面以Handler为例:

private static final int MESSAGE_SCROLL_TO = 1;
private static final int FRAME_COUNT = 30;
private static final int DELATED_TIME = 33;
private int mCount = 0;
@suppressLint("HandlerLeak")
private Handler handler = new handler(){
    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);
            mButton1.scrollTo(scrollX,0);
            mHandelr.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO , DELAYED_TIME);
            } 
        break;
        default : break;
        }
    }
}

3.4 View的事件分发机制

3.4.1 点击事件的传递规则

点击事件是MotionEvent。首先我们先看看下面一段伪代码,通过它我们可以理解到点击事件的传递规则:

public boolean dispatchTouchEvent (MotionEvent ev){
boolean consume = false;
if (onInterceptTouchEvnet(ev){
    consume = onTouchEvent(ev);
} else {
    consume = child.dispatchTouchEnvet(ev);
} 
return consume;
}

上面代码主要涉及到以下三个方法:

  • public boolean dispatchTouchEvent(MotionEvent ev);
    这个方法用来进行事件的分发。如果事件传递给当前view,则调用此方法。返回结果表示是否消耗此事件,受onTouchEvent和下级View的dispatchTouchEvent方法影响。

  • public boolean onInterceptTouchEvent(MotionEvent ev);
    这个方法用来判断是否拦截事件。在dispatchTouchEvent方法中调用。返回结果表示是否拦截。

  • public boolean onTouchEvent(MotionEvent ev);
    这个方法用来处理点击事件。在dispatchTouchEvent方法中调用,返回结果表示是否消耗事件。如果不消耗,则在同一个事件序列中,当前View无法再次接收到事件。


点击事件的传递规则:对于一个根ViewGroup,点击事件产生后,首先会传递给他,这时候就会调用他的dispatchTouchEvent方法,如果Viewgroup的onInterceptTouchEvent方法返回true表示他要拦截事件,接下来事件就会交给ViewGroup处理,调用ViewGroup的onTouchEvent方法;如果ViewGroup的onInteceptTouchEvent方法返回值为false,表示ViewGroup不拦截该事件,这时事件就传递给他的子View,接下来子View的dispatchTouchEvent方法,如此反复直到事件被最终处理。

当一个View需要处理事件时,如果它设置了OnTouchListener,那么onTouch方法会被调用,如果onTouch返回false,则当前View的onTouchEvent方法会被调用,返回true则不会被调用,同时,在onTouchEvent方法中如果设置了OnClickListener,那么他的onClick方法会被调用。由此可见处理事件时的优先级关系: onTouchListener > onTouchEvent >onClickListener

关于事件传递的机制,这里给出一些结论:

  1. 一个事件系列以down事件开始,中间包含数量不定的move事件,最终以up事件结束。

  2. 正常情况下,一个事件序列只能由一个View拦截并消耗。

  3. 某个View拦截了事件后,该事件序列只能由它去处理,并且它的onInterceptTouchEvent
    不会再被调用。

  4. 某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件( onTouchEvnet返回false) ,那么同一事件序列中的其他事件都不会交给他处理,并且事件将重新交由他的父元素去处理,即父元素的onTouchEvent被调用。

  5. 如果View不消耗ACTION_DOWN以外的其他事件,那么这个事件将会消失,此时父元素的onTouchEvent并不会被调用,并且当前View可以持续收到后续的事件,最终消失的点击事件会传递给Activity去处理。

  6. ViewGroup默认不拦截任何事件。

  7. View没有onInterceptTouchEvent方法,一旦事件传递给它,它的onTouchEvent方法会被调用。

  8. View的onTouchEvent默认消耗事件,除非他是不可点击的( clickable和longClickable同时为false) 。View的longClickable属性默认false,clickable默认属性分情况(如TextView为false,button为true)。

  9. View的enable属性不影响onTouchEvent的默认返回值。

  10. onClick会发生的前提是当前View是可点击的,并且收到了down和up事件。

  11. 事件传递过程总是由外向内的,即事件总是先传递给父元素,然后由父元素分发给子View,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的分发过程,但是ACTION_DOWN事件除外。

3.4.2 事件分发的源码解析

3.5 滑动冲突

在界面中,只要内外两层同时可以滑动,这个时候就会产生滑动冲突。滑动冲突的解决有固定的方法。

3.5.1 常见的滑动冲突场景

  1. 外部滑动和内部滑动方向不一致;
    比如viewpager和listview嵌套,但这种情况下viewpager自身已经对滑动冲突进行了处理。

  2. 外部滑动方向和内部滑动方向一致;

  3. 上面两种情况的嵌套。
    只要解决1和2即可。

3.5.2 滑动冲突的处理规则

对于场景一,处理的规则是:当用户左右( 上下) 滑动时,需要让外部的View拦截点击事件,当用户上下( 左右) 滑动的时候,需要让内部的View拦截点击事件。根据滑动的方向判断谁来拦截事件。

对于场景二,由于滑动方向一致,这时候只能在业务上找到突破点,根据业务需求,规定什么时候让外部View拦截事件,什么时候由内部View拦截事件。

场景三的情况相对比较复杂,同样根据需求在业务上找到突破点。

3.5.3 滑动冲突的解决方式

外部拦截法
所谓外部拦截法是指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截。下面是伪代码:

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 (父容器需要当前事件) {
    intercepted = true;
    } else {
    intercepted = flase;
    } 
    break;
    } 
case MotionEvent.ACTION_UP:
    intercepted = false;
    break;
default : 
    break;
} 
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;

针对不同冲突,只需修改父容器需要当前事件的条件即可。其他不需修改也不能修改。

  • ACTION_DOWN:必须返回false。因为如果返回true,后续事件都会被拦截,无法传递给子View。

  • ACTION_MOVE:根据需要决定是否拦截

  • ACTION_UP:必须返回false。如果拦截,那么子View无法接受up事件,无法完成click操作。而如果是父容器需要该事件,那么在ACTION_MOVE时已经进行了拦截,根据上一节的结论3,ACTION_UP不会经过onInterceptTouchEvent方法,直接交给父容器处理。

内部拦截法
内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗,否则就交由父容器进行处理。这种方法与Android事件分发机制不一致,需要配合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);
    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;
    }
}

优化滑动体验:

mScroller.abortAnimation();

外部拦截法实例:HorizontalScrollViewEx



4 View的工作原理

主要内容

  • View的工作原理

  • 自定义View的实现方式

  • 自定义View的底层工作原理,比如View的测量流程、布局流程、绘制流程

  • View常见的回调方法,比如构造方法、onAttach.onVisibilityChanged/onDetach等

4.1 初识ViewRoot和DecorView

ViewRoot的实现是 ViewRootImpl 类,是连接WindowManager和DecorView的纽带,View的三大流程( mearsure、layout、draw) 均是通过ViewRoot来完成。当Activity对象被创建完毕后,会将DecorView添加到Window中,同时创建 ViewRootImpl 对象,并将ViewRootImpl 对象和DecorView建立连接,源码如下:

root = new ViewRootImpl(view.getContext(),display);
root.setView(view,wparams, panelParentView);

View的绘制流程是从ViewRoot的performTraversals开始的

  1. measure用来测量View的宽高

  2. layout来确定View在父容器中的位置

  3. draw负责将View绘制在屏幕上

performTraversals会依次调用 performMeasure 、 performLayout 和performDraw 三个方法,这三个方法分别完成顶级View的measure、layout和draw这三大流程。其中 performMeasure 中会调用 measure 方法,在 measure 方法中又会调用 onMeasure 方法,在 onMeasure 方法中则会对所有子元素进行measure过程,这样就完成了一次measure过程;子元素会重复父容器的measure过程,如此反复完成了整个View数的遍历。另外两个过程同理。

  • Measure完成后, 可以通过getMeasuredWidth 、getMeasureHeight 方法来获取View测量后的宽/高。特殊情况下,测量的宽高不等于最终的宽高,详见后面。

  • Layout过程决定了View的四个顶点的坐标和实际View的宽高,完成后可通过 getTop 、 getBotton 、 getLeft 和 getRight 拿到View的四个定点坐标。

DecorView作为顶级View,其实是一个 FrameLayout ,它包含一个竖直方向的 LinearLayout ,这个 LinearLayout 分为标题栏和内容栏两个部分。
!

<div  align="center">
<img src="http://images2015.cnblogs.com/blog/500720/201609/500720-20160925174505236-1295369287.png" width = "150" height = "200" alt="图片" align=center />
</div>

在Activity通过setContextView所设置的布局文件其实就是被加载到内容栏之中的。这个内容栏的id是 R.android.id.content ,通过 ViewGroup content = findViewById(R.android.id.content); 可以得到这个contentView。View层的事件都是先经过DecorView,然后才传递到子View。

4.2 理解MeasureSpec

MeasureSpec决定了一个View的尺寸规格。但是父容器会影响View的MeasureSpec的创建过程。系统将View的 LayoutParams 根据父容器所施加的规则转换成对应的MeasureSpec,然后根据这个MeasureSpec来测量出View的宽高。

4.2.1 MeasureSpec

MeasureSpec代表一个32位int值,高2位代表SpecMode( 测量模式) ,低30位代表SpecSize( 在某个测量模式下的规格大小) 。
SpecMode有三种:

  • UNSPECIFIED :父容器不对View进行任何限制,要多大给多大,一般用于系统内部

  • EXACTLY:父容器检测到View所需要的精确大小,这时候View的最终大小就是SpecSize所指定的值,对应LayoutParams中的 match_parent 和具体数值这两种模式

  • AT_MOST :对应View的默认大小,不同View实现不同,View的大小不能大于父容器的SpecSize,对应 LayoutParams 中的 wrap_content

4.2.2 MeasureSpec和LayoutParams的对应关系

对于DecorView,其MeasureSpec由窗口的尺寸和其自身的LayoutParams共同确定。而View的MeasureSpec由父容器的MeasureSpec和自身的LayoutParams共同决定。

View的measure过程由ViewGroup传递而来,参考ViewGroup的 measureChildWithMargins 方法,通过调用子元素的 getChildMeasureSpec 方法来得到子元素的MeasureSpec,再调用子元素的 measure 方法。

parentSize是指父容器中目前可使用的大小。

  1. 当View采用固定宽/高时( 即设置固定的dp/px) ,不管父容器的MeasureSpec是什么,View的MeasureSpec都是EXACTLY模式,并且大小遵循我们设置的值。

  2. 当View的宽/高是 match_parent 时,View的MeasureSpec都是EXACTLY模式并且其大小等于父容器的剩余空间。

  3. 当View的宽/高是 wrap_content 时,View的MeasureSpec都是AT_MOST模式并且其大小不能超过父容器的剩余空间。

  4. 父容器的UNSPECIFIED模式,一般用于系统内部多次Measure时,表示一种测量的状态,一般来说我们不需要关注此模式。

4.3 View的工作流程

4.3.1 measure过程

View的measure过程

直接继承View的自定义控件需要重写 onMeasure 方法并设置 wrap_content ( 即specMode是 AT_MOST 模式) 时的自身大小,否则在布局中使用 wrap_content 相当于使用 match_parent 。对于非 wrap_content 的情形,我们沿用系统的测量值即可。

  @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
      // 在 MeasureSpec.AT_MOST 模式下,给定一个默认值mWidth,mHeight。默认宽高灵活指定
      //参考TextView、ImageView的处理方式
      //其他情况下沿用系统测量规则即可
    if (widthSpecMode == MeasureSpec.AT_MOST
            && heightSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(mWith, mHeight);
    } else if (widthSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(mWith, heightSpecSize);
    } else if (heightSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(widthSpecSize, mHeight);
    }
}

ViewGroup的measure过程

ViewGroup是一个抽象类,没有重写View的 onMeasure 方法,但是它提供了一个 measureChildren 方法。这是因为不同的ViewGroup子类有不同的布局特性,导致他们的测量细节各不相同,比如 LinearLayout 和 RelativeLayout ,因此ViewGroup没办法同一实现 onMeasure方法。

measureChildren方法的流程:

  1. 取出子View的 LayoutParams

  2. 通过 getChildMeasureSpec 方法来创建子元素的 MeasureSpec

  3. 将 MeasureSpec 直接传递给View的measure方法来进行测量

通过LinearLayout的onMeasure方法里来分析ViewGroup的measure过程:

  1. LinearLayout在布局中如果使用match_parent或者具体数值,测量过程就和View一致,即高度为specSize

  2. LinearLayout在布局中如果使用wrap_content,那么它的高度就是所有子元素所占用的高度总和,但不超过它的父容器的剩余空间

  3. LinearLayout的的最终高度同时也把竖直方向的padding考虑在内

View的measure过程是三大流程中最复杂的一个,measure完成以后,通过 getMeasuredWidth/Height 方法就可以正确获取到View的测量后宽/高。在某些情况下,系统可能需要多次measure才能确定最终的测量宽/高,所以在onMeasure中拿到的宽/高很可能不是准确的。

如果我们想要在Activity启动的时候就获取一个View的宽高,怎么操作呢?因为View的measure过程和Activity的生命周期并不是同步执行,无法保证在Activity的 onCreate、onStart、onResume 时某个View就已经测量完毕。所以有以下四种方式来获取View的宽高:

  1. Activity/View#onWindowFocusChanged
    onWindowFocusChanged这个方法的含义是:VieW已经初始化完毕了,宽高已经准备好了,需要注意:它会被调用多次,当Activity的窗口得到焦点和失去焦点均会被调用。

  2. view.post(runnable)
    通过post将一个runnable投递到消息队列的尾部,当Looper调用此runnable的时候,View也初始化好了。

  3. ViewTreeObserver
    使用 ViewTreeObserver 的众多回调可以完成这个功能,比如OnGlobalLayoutListener 这个接口,当View树的状态发送改变或View树内部的View的可见性发生改变时,onGlobalLayout 方法会被回调,这是获取View宽高的好时机。需要注意的是,伴随着View树状态的改变, onGlobalLayout 会被回调多次。

  4. view.measure(int widthMeasureSpec,int heightMeasureSpec)
    手动对view进行measure。需要根据View的layoutParams分情况处理:

    • match_parent:
      无法measure出具体的宽高,因为不知道父容器的剩余空间,无法测量出View的大小

    • 具体的数值( dp/px):

        int widthMeasureSpec = MeasureSpec.makeMeasureSpec(100,MeasureSpec.EXACTLY);
        int heightMeasureSpec = MeasureSpec.makeMeasureSpec(100,MeasureSpec.EXACTLY);
        view.measure(widthMeasureSpec,heightMeasureSpec);
    • wrap_content:

        int widthMeasureSpec = MeasureSpec.makeMeasureSpec((1<<30)-1,MeasureSpec.AT_MOST);
        // View的尺寸使用30位二进制表示,最大值30个1,在AT_MOST模式下,我们用View理论上能支持的最大值去构造MeasureSpec是合理的
        int heightMeasureSpec = MeasureSpec.makeMeasureSpec((1<<30)-1,MeasureSpec.AT_MOST);
        view.measure(widthMeasureSpec,heightMeasureSpec);

4.3.2 layout过程

layout的作用是ViewGroup用来确定子View的位置,当ViewGroup的位置被确定后,它会在onLayout中遍历所有的子View并调用其layout方法,在 layout 方法中, onLayout 方法又会被调用。

View的 layout 方法确定本身的位置,源码流程如下:

  1. setFrame 确定View的四个顶点位置,即确定了View在父容器中的位置

  2. 调用 onLayout 方法,确定所有子View的位置,和onMeasure一样,onLayout的具体实现和布局有关,因此View和ViewGroup均没有真正实现 onLayout 方法。

以LinearLayout的 onLayout 方法为例:

  1. 遍历所有子View并调用 setChildFrame 方法来为子元素指定对应的位置

  2. setChildFrame 方法实际上调用了子View的 layout 方法,形成了递归

View的测量宽高和最终宽高的区别:
在View的默认实现中,View的测量宽高和最终宽高相等,只不过测量宽高形成于measure过程,最终宽高形成于layout过程。但重写view的layout方法可以使他们不相等。

4.3.3 draw过程

View的绘制过程遵循如下几步:

  1. 绘制背景 drawBackground(canvas)

  2. 绘制自己 onDraw

  3. 绘制children dispatchDraw 遍历所有子View的 draw 方法

  4. 绘制装饰 onDrawScrollBars

ViewGroup会默认启用 setWillNotDraw 为ture,导致系统不会去执行 onDraw ,所以自定义ViewGroup需要通过onDraw来绘制内容时,必须显式的关闭 WILL_NOT_DRAW 这个优化标记位,即调用 setWillNotDraw(false);

4.4 自定义View

4.4.1 自定义View的分类

继承View 重写onDraw方法
通过 onDraw 方法来实现一些不规则的效果,这种效果不方便通过布局的组合方式来达到。这种方式需要自己支持 wrap_content ,并且padding也要去进行处理。

继承ViewGroup派生特殊的layout
实现自定义的布局方式,需要合适地处理ViewGroup的测量、布局这两个过程,并同时处理子View的测量和布局过程。

继承特定的View子类( 如TextView、Button)
扩展某种已有的控件的功能,比较简单,不需要自己去管理 wrap_content 和padding。

继承特定的ViewGroup子类( 如LinearLayout)
比较常见,实现几种view组合一起的效果。与方法二的差别是方法二更接近底层实现。

4.4.2 自定义View须知

  1. 直接继承View或ViewGroup的控件, 需要在onmeasure中对wrap_content做特殊处理。指定wrap_content模式下的默认宽/高。

  2. 直接继承View的控件,如果不在draw方法中处理padding,那么padding属性就无法起作用。直接继承ViewGroup的控件也需要在onMeasure和onLayout中考虑padding和子元素margin的影响,不然padding和子元素的margin无效。

  3. 尽量不要用在View中使用Handler,因为没必要。View内部提供了post系列的方法,完全可以替代Handler的作用。

  4. View中有线程和动画,需要在View的onDetachedFromWindow中停止。当View不可见时,也需要停止线程和动画,否则可能造成内存泄漏。

  5. View带有滑动嵌套情形时,需要处理好滑动冲突

4.4.3 自定义View实例

自定义属性设置方法:

  1. 在values目录下创建自定义属性的XML,如attrs.xml。

     <?xml version="1.0" encoding="utf-8"?>
     <resources>
         <declare-styleable name="CircleView">
             <attr name="circle_color" format="color" />
         </declare-styleable>
     </resources>
  2. 在View的构造方法中解析自定义属性的值并做相应处理,这里我们解析circle_color。

     public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
         super(context, attrs, defStyleAttr);
         TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
         mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED);
         a.recycle();
         init();
     }
  3. 在布局文件中使用自定义属性

     <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     android:background="#ffffff"
     android:orientation="vertical" >
     <com.ryg.chapter_4.ui.CircleView
         android:id="@+id/circleView1"
         android:layout_width="wrap_content"
         android:layout_height="100dp"
         android:layout_margin="20dp"
         android:background="#000000"
         android:padding="20dp"
         app:circle_color="@color/light_green" />
     </LinearLayout>

    Android中的命名空间

  • 继承ViewGroup派生特殊的layout:HorizontalScrollViewEx
    onMeasure方法中,首先判断是否有子元素,没有的话根据LayoutParams中的宽高做相应处理。然后判断宽高是不是wrap_content,如果宽是,那么HorizontalScrollViewEx的宽就是所有所有子元素的宽度之和。如果高是wrap_content,HorizontalScrollViewEx的高度就是第一个子元素的高度。同时要处理padding和margin。
    onLayout方法中,在放置子元素时候也要考虑padding和margin。

4.4.4 自定义View的思想

  • 掌握基本功,比如View的弹性滑动、滑动冲突、绘制原理等

  • 面对新的自定义View时,对其分类并选择合适的实现思路。

原文链接:http://www.apkbus.com/blog-892197-78027.html

0人推荐
随时随地看视频
慕课网APP