37、自定义控件详解(二)-- View和ViewGroup

一、View和ViewGroup绘制

1.1、View的位置参数

37、自定义控件详解(二)-- View和ViewGroup

我们很容易就得出宽高和坐标的关系:

width = right - left
height = bottom - top

那么,如何获得View的位置参数:

  • 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

View在平移过程中,top和left表示的是原始左上角的位置信息,并不会发生改变,此时发生改变的是x、y、tanslationX、translationY这四个参数。

1.2、View的绘制流程

View的工作流程主要是指measure、layout和draw三大流程,即测量、布局和绘制。

37、自定义控件详解(二)-- View和ViewGroup

1.3、View的测量

在测量过程中,系统会将View的LayoutParams根据父容器所施加的规则转换成对应的MeasureSpec,然后再根据这个MeasureSpec来测量View的宽高。

MeasureSpec代表一个32位的int值,高2位代表SpecMode,低30位代表SpecSize。

SpecMode是指测量模式,而SpecSize是指某种测量模式下的规格大小。我们查看下MeasureSpec源码中部分代码:

public static class MeasureSpec {
    private static final int MODE_SHIFT = 30;
    private static final int MODE_MASK  = 0x3 << MODE_SHIFT;
    public static final int UNSPECIFIED = 0 << MODE_SHIFT;
    public static final int EXACTLY     = 1 << MODE_SHIFT;
    public static final int AT_MOST     = 2 << MODE_SHIFT;
    public static int makeMeasureSpec(int size, int mode) {
        if (sUseBrokenMakeMeasureSpec) {
            return size + mode;
        } else {
            return (size & ~MODE_MASK) | (mode & MODE_MASK);
        }
    }
    public static int getMode(int measureSpec) {
        return (measureSpec & MODE_MASK);
    }
    public static int getSize(int measureSpec) {
        return (measureSpec & ~MODE_MASK);
    }
    .....
}

MeasureSpec通过将SpecMode和SpecSize打包成一个int值来避免过多的对象内存分配,为方便操作,并提供了打包和解包的方法。

测量的模式可以分为以下三种:

  • EXACTLY

    即精确模式,当控件的layout_width或layout_height为具体数值时,系统使用的是EXACTLY模式。

  • AT_MOST

    最大值模式,当控件的layout_width或layout_height为wrap_content或match_parent时,控件的尺寸不要超过父控件允许的最大尺寸即可。

  • UNSPECIFIED

     未指定模式,它不指定其大小测量模式,View想多大就多大,通常情况下在绘制自定义View时才会使用。

系统最终会调用setMeasuredDimension(int measuredWidth,int measuredHeight)方法将测量后的宽高传递进去,以完成测量操作。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec));
}

/**测量宽度的模板代码*/
private int measureWidth(int measureSpec){
    int result = 0;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);
    
    if(specMode == MeasureSpec.EXACTLY){    // 精确数值
        result = specSize;
    }else{                                    // 非精确数值
        result = 200;
        if(specMode == MeasureSpec.AT_MOST){// 自动包含
            result = Math.min(result, specSize);// 取出指定大小与specSize中最小一个作为最后测量值。
        }
    }
    return result;
}

/**测量高度的模板代码*/
private int measureHeight(int measureSpec){
    int result = 0;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);
    
    if(specMode == MeasureSpec.EXACTLY){
        result = specSize;
    }else{
        result = 200;
        if(specMode == MeasureSpec.AT_MOST){
            result = Math.min(result, specSize);
        }
    }
    return result;
}

a) 布局文件中指定精确的宽高值是400px时,View会根据指定的宽高进行设定。

b) 当指定宽高属性为match_parent时,View会填充整个父布局。

c) 当指定宽高属性为wrap_content时,如果不重写onMeasure()方法则会填充整个父布局,重写的话则会根据内容自动包含。

1.4、View的绘制

1、装载画布

创建画布有两种方式:

Canvas canvas = new Canvas();   或    Canvas canvas = new Canvas(bitmap);

当在创建画布传入bitmap对象时,bitmap和画布是紧紧相连的,这个过程我们称之为装载画布。

这个bitmap用来存储所有绘制在Canvas上的像素信息。且Canvas调用所有的Canvas.drawXXX方法都发生在该bitmap上。

装载画布时,当Canvas将绘制效果作用在bitmap时,刷新view就会改变bitmap,如果非装载画布模式下,改变的是bitmap对象,并让view重绘。

2、draw的源码

Android系统中要自定义view,首先需要了解Android的view加载机制。主要有三个方法:

1、onMeasure()     //计算出view自身大小

2、onLayout()     //仅在ViewGroup中,用来为子view指定位置(left,top)

3、onDraw()      //view绘制内容

下面根据源码中的相关说明,进一步分析控件的绘制操作及顺序:

/*
  * Draw traversal performs several drawing steps which must be executed
  * in the appropriate order:
  *
  *  1. Draw the background  (绘制控件设置的背景,系统已在view.draw()中绘制,只要在xml中指定背景即可)
  *  2. If necessary, save the canvas' layers to prepare for fading  
  *  3. Draw view's content  (可以重写, onDraw(canvas);)
  *  4. Draw children      (可重写,用来分发canvas到子控件,具体看ViewGroup。
  *                        对应方法dispatchDraw(canvas);此方法依次调用了子控件的draw()方法)
  *  5. If necessary, draw the fading edges and restore layers (绘制控件四周的阴影渐变效果)
  *  6. Draw decorations (scrollbars for instance) (用来绘制滚动条,对应方法onDrawScrollBars(canvas);。
  *      onDrawHorizontalScrollBar()和onDrawVerticalScrollBar()被隐藏了无法重写,也许有其他方法重写滚动条)
  */

1.5、ViewGroup的测量

1、MeasureSpec和LayoutParams

在测量的时候,系统会将LayoutParams在父容器的约束下转换成对应的MeasureSpec来确定View测量后的宽高。

*View(DecorView)和普通View的MeasureSpec是存在区别的:

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

2、三个测量方法

// measureChild(View, int, int)为子组件添加Padding   
measureChild(child, parentWidthMeasureSpec, parentHeightMeasureSpec);
// measureChildren(int, int)根据指定的高和宽来测量所有子View中显示参数非GONE的组件。  
measureChildren(widthMeasureSpec, heightMeasureSpec);
// measureChildWithMargins(View, int, int, int, int)测量指定的子组件,为子组件添加Padding和Margin。
measureChildWithMargins(child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed);

 

3、三种宽度和高度

  • getMeasuredWidth(): 对View上的内容进行测量后得到的View内容占据的宽度。
  • getWidth(): View在设定好布局后整个View的宽度,也就是在onLayout之后。
  • getLayoutParams().width:测量后就确定值,猜测getLayoutParams.width在getMeasureWidth基础上多了margin和padding。

此处未完成###########待续

 

1.6、ViewGroup的绘制

ViewGroup为wrap_content时,测量是通过遍历所有子view,从而调子View的Measure方法获得每个子View的测量结果,

然后将子View放到合适的位置进行Layout过程。且Layout过程同样是遍历调用子View的Layout方法,指定其具体的位置来决定其布局位置。

通常情况下ViewGroup不需要进行绘制,因为其本身没有需要绘制的东西,如果不是指定背景色,那么ViewGroup的onDraw方法不会被调用。

但是,ViewGroup会通过dispatchDraw()方法来绘制其子View。

1、onDraw()和dispatchDraw的区别:

  • 绘制View本身内容时,可以调用View.onDraw(Canvas canvas)方法。
  • 绘制View的子View的内容时,可以调用diapatchDraw方法。

2、View中通常有如下重要的回调方法:

  • onFinishInflate():         从XML加载组件后回调。
  • onSizeChanged():           组件大小改变时回调。
  • onMeasure():               回调该方法进行测量。
  • onLayout():                回调该方法来确定显示的位置。
  • onTouchEvent():            监听到触摸事件时回调。

3、回调的顺序

View:

onFinishInflate -> onMeasure() -> onMeasure() -> onSizeChange() -> onLayout()  -> onMeasure() -> onMeasure() -> onLayout() -> onDraw()
之后的操作会一直onDraw()去重绘

ViewGroup

 

二、手势、速度追踪、滑动

1.1、MotionEvent和TouchSlop

1. MotionEvent

在手指接触屏幕后会产生一系列事,典型的事件类型如下:

  • ACTION_DOWN--------手指刚接触屏幕。
  • ACTION_MOVE--------手指在屏幕上移动。
  • ACTION_UP----------手指从屏幕上松开的一瞬间。

正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件,考虑如下几种情况:

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

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

上面是典型的事件序列,同时通过MotionEvent对象可以得到 x、y的坐标:

  • getX和getY:返回的是相对于当前View的左上角的x和y坐标。
  • getRawX和getRawY:返回的是相对于手机屏幕左上角的x和y坐标。

2. TouchSlop

TouchSlop是系统所能识别出被认为滑动的最小距离。

当手指在屏幕上滑动时,如果两次滑动之间的距离小于这个常量,那么系统会认为不是一个有效的滑动。

不同的设备,该常量的值也是不同的,可以通过如下方式获取这个常量:

ViewConfiguration.get(this).getScaledTouchSlop();

当然,在源码中我们可以找到该常量的定义,在framework.base/core/res/res/values/config.xml中。

 

1.2、VelocityTracker、GestureDetector和Scroller

1. VelocityTracker

速度追踪,用于追踪手指在滑动过程中的速度,包括水平和垂直方向的速度。

使用方式也非常简单,在View的onTouchEvent方法中追踪当前单击事件的速度:

VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);

当我们想知道滑动的速度,可以采用如下的方法:

VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);
velocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();

调用getXVelocity或getYVelocity获取速度前,必须调用computeCurrentVelocity方法(参数表示时间间隔)。

这里的速度是指一段时间内手指所滑过的像素数,这里时间间隔为1000毫秒,如果从左到右移动100像素,那么水平速度是100/s。

注意速度是可以为负数的,当手指从右向左滑动时,水平方向的速度即为负值,速度计算公式如下:

速度 = (终点位置 - 起点位置)/ 时间段

当不需要再使用该对象时,需要释放资源等操作

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

2. GestureDetector

手势识别,用于辅助用户的单击、滑动、长按、双击等事件。

首先需要创建一个GestureDetector对象并实现onGestureListener接口,或者实现onDoubleTapListener来监听双击事件。

a)声明左右滑动的动画,一共有四个,必须放在res/anim目录下:

trans_next_in.xml

<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="100%"
    android:toXDelta="0"
    android:fromYDelta="0"
    android:toYDelta="0"
    android:duration="300">
</translate>

trans_next_out.xml

<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="0"
    android:toXDelta="-100%"
    android:fromYDelta="0"
    android:toYDelta="0"
    android:duration="300">
</translate>

trans_pre_in.xml

<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="-100%"
    android:toXDelta="0"
    android:fromYDelta="0"
    android:toYDelta="0"
    android:duration="300">
</translate>

tran_pre_out.xml

<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromXDelta="0"
    android:toXDelta="100%"
    android:fromYDelta="0"
    android:toYDelta="0"
    android:duration="300">
</translate>

动画的播放需要用到如下方法:

//两个参数表示进入和退出的动画,要写在startactivity的后面生效
overridePendingTransition(R.anim.trans_next_in, R.anim.trans_next_out);

b) 在代码中加入如下代码来对手势进行判断

(1)声明手势识别器

(2)初始化手势识别器

(3)使用手势识别器去识别动作

//该方法最好做成成员变量可以响应下方的触摸动作
GestureDetector gestureDetector = new GestureDetector(this,new GestureDetector.SimpleOnGestureListener(){
    //手指在屏幕上滑动
    //e1,e2:手指的事件:手指第一次触摸屏幕触发->手指离开屏幕触发
    //vX vY:水平和垂直方向的速度
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2,float velocityX, float velocityY) {
        //过滤掉Y的移动
        if(Math.abs(e1.getRawY()-e2.getRawY())>100){
            //ToastUtils.show(SetupBaseActivity.this,"动作不合法");
            return true;
        }
        
        if(e1.getRawX()-e2.getRawX()>150){
            //从右向左滑,显示下一个界面
            //showNext();
            overridePendingTransition(R.anim.trans_next_in, R.anim.trans_next_out);
            return true;
        }else if (e2.getRawX()-e1.getRawX()>150) {
            //从左向右滑,显示上一个界面
            //showPre();
            overridePendingTransition(R.anim.trans_pre_in, R.anim.trans_pre_out);
            return true;
        }
        return super.onFling(e1, e2, velocityX, velocityY);
    }
});

c) 使用手势识别器去识别动作,重写Activity触屏的方法onTouchEvent

//3.使用手势识别器去识别用户的动作
@Override
public boolean onTouchEvent(MotionEvent event) {
    //将手势事件传递给手势识别器
    gestureDetector.onTouchEvent(event);
    return super.onTouchEvent(event);
}

3. onTouchEvent

除了使用GestureDetector来判断手势事件之外,我们还可以使用onTouchEvent来判断手势事件

@Override
public boolean onTouchEvent(MotionEvent event) {
    float downX = 0, downY = 0, upX, upY;
    //继承了Activity的onTouchEvent方法,直接监听点击事件
    if(event.getAction() == MotionEvent.ACTION_DOWN) {
        //当手指按下的时候
        downX = event.getX();
        downY = event.getY();
    }
    if(event.getAction() == MotionEvent.ACTION_UP) {
        //当手指离开的时候
        upX = event.getX();
        upY = event.getY();
        if((downY - upY > 50) && (Math.abs(upX - downX) <= ( downY - upY))) {
            Toast.makeText(TaskToMoneyActivity.this, "向上滑", Toast.LENGTH_SHORT).show();
        } else if((upY - downY > 50) && (Math.abs(upX - downX) <= (upY - downY))) {
            Toast.makeText(TaskToMoneyActivity.this, "向下滑", Toast.LENGTH_SHORT).show();
        } else if((downX - upX>50) && (Math.abs(upY - downY) < (downX - upX))) {
            Toast.makeText(TaskToMoneyActivity.this, "向左滑", Toast.LENGTH_SHORT).show();
        } else if((upX - downX>50) && (Math.abs(upY - downY) < (upX - downX))) {
            Toast.makeText(TaskToMoneyActivity.this, "向右滑", Toast.LENGTH_SHORT).show();
        }
    }
    return super.onTouchEvent(event);
}

4. Scroller

弹性滑动对象,用于实现View的弹性滑动。

当我们使用scrollTo/scrollBy方法来进行滑动时,其过程是瞬间完成,此时就可以考虑使用Scoller来实现过渡滑动的效果。

它需要和computeScroll方法配合使用才能完成共同的功能。

Scroller mScroller = new Scroller(getContext());
// 缓慢滚动到指定位置
private void smoothScrollTo(int destX , int destY){
    int scrollX = getScrollX();
    int scrollY = getScrollY();
    int delta = destX - scrollX;
    // 1000ms内滑向destX,缓慢移动
    mScroller.startScroll(scrollX, 0, delta, 0, 1000);
    invalidate();
}
@Override
public void computeScroll() {
    super.computeScroll();
    if(mScroller.computeScrollOffset()){
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

 

##############################################################################