android自定义view其实包含了很多知识,我们先从View的概念、绘制原理、坐标系及位置参数、一般自定义view的步骤总结,然后更丰富的效果,包括滑动、画图等。

View是什么

View是一种界面层的控件的一种抽象,它代表了一个控件。

View的生命周期

Category

Methods

Description

Creation

Constructors

几个View的构造函数

onFinishInflate()

当系统解析完View之后调用onFinishInflate方法

Layout

onMeasure(int, int)

确定所有子View的大小

onLayout(boolean, int, int, int, int)

当ViewGroup分配所有的子View的大小和位置时触发

onSizeChanged(int, int, int, int)

当view的大小发生变化时触发

Drawing

onDraw(android.graphics.Canvas)

view渲染内容的细节

Event processing

onKeyDown(int, KeyEvent)

有按键按下后触发

onKeyUp(int, KeyEvent)

有按键按下后弹起时触发

onTrackballEvent(MotionEvent)

轨迹球事件

onTouchEvent(MotionEvent)

触屏事件

Focus

onFocusChanged(boolean, int, android.graphics.Rect)

当View获取或失去焦点时触发

onWindowFocusChanged(boolean)

当窗口包含的view获取或失去焦点时触发

Attaching

onAttachedToWindow()

当view被附着到一个窗口时触发

onDetachedFromWindow()

当view离开附着的窗口时触发,该方法和 onAttachedToWindow() 是相反

onWindowVisibilityChanged(int)

当窗口中包含的可见的view发生变化时触发

View的绘制原理

在《Android开发艺术探索》第4章View的工作原理已经从源码的角度详细分析了这个绘制过程,这里主要是精简总结。

绘制包含了measure、layout、draw三大流程,即测量,布局和绘制,其中measure确定View的测试了宽/高,layout确定View的最终宽/高和四个顶点的位置,而draw则将View绘制到屏幕上。

View树的层级结构

View树的顶层是DecorView,DecorView其实是一个FrameLayout,一般情况下它内部会包含一个垂直方向的LinearLayout,里面包含了两部分,标题栏和内容栏(即android.R.id.content)。

View树顶层DecorView与ViewRoot的关系

ViewRoot对应于ViewRootImpl类,它是连接WindowManager和DecorView的纽带,View的三大流程均是通过ViewRoot来完成的。在ActivityThread中,当Activity对象被创建完毕后,会将DecorView添加到Window中,同时会创建ViewRootImpl对象,并将ViewRootImpl对象和DecorView建立关联。

View的绘制流程是从ViewRoot的performTraversals方法开始的,它经过measure、layout和draw三个过程才最终将一个View绘制出来,其中measure用来测量View的宽和高,layout用来确定View在父容器中的放置位置,而draw则负责将View绘制在屏幕上。

Android开发 自定义veiw onDraw 为啥会一直执行 没有调用 android自定义view的三大流程_标题栏


measure过程

测量的过程使用了MeasureSpec这个使者去完成测量工作,它表示一个32位int值,高2位表示SpecMode,低30位表示SpecSize,前者是指测量模式,后者指某种测量模式下的规格大小。

它是用于描述一个View的测量规格,并且通过父MeasureSpec生成子MeasureSpec,层层传递下去,完成整个View树的测量。

MeasureSpec的生成有二种情况:

对于顶级View,它的MeasureSpec生成依赖窗口大小及自身的宽高;

对于普通View,它的MeasureSpec生成依赖父容器剩余空间(即父MeasureSpec的测量大小减去父padding+子margin的大小)和父容器测量模式(即父MeasureSpec的测量模式)以及自身宽高;

Android开发 自定义veiw onDraw 为啥会一直执行 没有调用 android自定义view的三大流程_宽高_02


另一个要注意的是,测量结束后会回调View的onMeasure方法,这个方法参数是父view计算出来的这个View自己的高MeasureSpec和宽MeasureSpec,这个方法默认View类已有实现。代码如下:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),  
                         getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));   
}

这里说明了两件事,一是View默认是怎么通过MeasureSpec计算view的尺寸,二是计算出的尺寸是需要调用setMeasuredDimension设置给View的。
注意,ViewGroup是抽象类,onMeasure由于布局特性不同计算方式需由子类去实现。 源码详细过程这里就不赘述了,可以自己去看书
layout的过程
从performLayout方法开始说,我们先看它的源码:

private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
        int desiredWindowHeight) {
    mLayoutRequested = false;
    mScrollMayChange = true;
    mInLayout = true;

    final View host = mView;
    if (DEBUG_ORIENTATION || DEBUG_LAYOUT) {
        Log.v(TAG, "Laying out " + host + " to (" +
                host.getMeasuredWidth() + ", " + host.getMeasuredHeight() + ")");
    }

    Trace.traceBegin(Trace.TRACE_TAG_VIEW, "layout");
    try {
        host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight()); // 1
        //省略...
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }
    mInLayout = false;
}

由上面的代码可以看出,直接调用了①号的host.layout方法,host也就是DecorView,那么对于DecorView来说,调用layout方法,就是对它自身进行布局,注意到传递的参数分别是0,0,host.getMeasuredWidth,host.getMeasuredHeight,它们分别代表了一个View的上下左右四个位置,显然,DecorView的左上位置为0,然后宽高为它的测量宽高。由于View的layout方法是final类型,子类不能重写,因此我们直接看View#layout方法即可:

public void layout(int l, int t, int r, int b) {
    if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
        onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
        mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
    }

    int oldL = mLeft;
    int oldT = mTop;
    int oldB = mBottom;
    int oldR = mRight;

    boolean changed = isLayoutModeOptical(mParent) ?
            setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b); // 1

    if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
        onLayout(changed, l, t, r, b);             // 2
        mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;

        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnLayoutChangeListeners != null) {
            ArrayList<OnLayoutChangeListener> listenersCopy =
                    (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
            int numListeners = listenersCopy.size();
            for (int i = 0; i < numListeners; ++i) {
                listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
            }
        }
    }

    mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
    mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
}

首先看①号代码,调用了setFrame方法,并把四个位置信息传递进去,这个方法用于确定View的四个顶点的位置,即初始化mLeft,mRight,mTop,mBottom这四个值,当初始化完毕后,ViewGroup的布局流程也就完成了
那么,我们先看View#setFrame方法:

protected boolean setFrame(int left, int top, int right, int bottom) {
    //省略...

    mLeft = left;
    mTop = top;
    mRight = right;
    mBottom = bottom;
    mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);

    //省略...
    return changed;
}

可以看出,它对mLeft、mTop、mRight、mBottom这四个值进行了初始化,对于每一个View,包括ViewGroup来说,以上四个值保存了Viwe的位置信息,所以这四个值是最终宽高,也即是说,如果要得到View的位置信息,那么就应该在layout方法完成后调用getLeft()、getTop()等方法来取得最终宽高,如果是在此之前调用相应的方法,只能得到0的结果,所以一般我们是在onLayout方法中获取View的宽高信息。
在设置ViewGroup自身的位置完成后,我们看到会接着调用②号方法,即onLayout()方法,该方法在ViewGroup中调用,用于确定子View的位置,即在该方法内部,子View会调用自身的layout方法来进一步完成自身的布局流程。
draw的过程
这个过程比较简单,它的作用是将View绘制到屏幕上。View的绘制过程遵循如下几步:
(1)绘制背景background.draw(canvas)
(2) 绘制自己(onDraw)
(3) 绘制children(dispatchDraw)
(4) 绘制装饰(onDrawScrollBars)

View的位置参数
5个永远不变的原始位置参数

View的位置主要由它的四个顶点来决定,分别对应于View的四个属性:top、left、right、bottom,其中top为左上角纵坐标,left为左上角横坐标,right为右下角横坐标,bottom为右下角纵坐标。如下图

Android开发 自定义veiw onDraw 为啥会一直执行 没有调用 android自定义view的三大流程_ci_03


这四个属性获取方式:getLeft/Right/Top/Bottom。

另外还有一个属性elevation,它表示View的Z轴高度和父容器所在的Z轴高度的距离,通过view.getElevation()方法获取(这个属性是Android 5.0之后添加的新属性)

注意:View的这五个属性值代表的是View的原始位置坐标值,无论这个View被移动到了什么位置,或者被缩放、旋转了,这五个值都是永远不会改变。

6个随动画或滑动改变的位置参数

translationX、translationY、translationZ这三个参数代表的是在动画或者滑动View的时候,View的当前位置相对于其原始位置平移的距离:
translationX:在滑动过程中,View当前位置的最左边和这个View原始位置的最左边的距离,通过view.getTranslationX()方法获取;
translationY:在滑动过程中,View当前位置的最上边和这个View原始位置的最上边的距离,通过view.getTranslationY()方法获取;
translationZ:在动画过程中,View当前位置的Z轴高度和这个View原始Z轴高度的距离,通过view.getTranslationZ()方法获取(这个方法是Android 5.0之后添加的新方法)。
x、y、x这三个参数代表的是View的当前位置相对于其父控件的距离:
x:目标View的当前位置的最左边和这个View所在父布局的最左边的距离,通过view.getX()方法获取;
y:目标View的当前位置的最上边和这个View所在父布局的最上边的距离,通过view.getY()方法获取;
z:目标View的当前位置的Z轴位置和这个View所在父布局的Z轴位置的距离,通过view.getZ()方法获取(这个方法是Android 5.0之后添加的新方法)。
这三个参数和前面的几个参数的关系公式如下:
x = left + translationX;
y = top + translationY;
z = elevation + translationZ;

MotionEvent相关位置参数

  MotionEvent是我们用来操作View的触摸事件的类,当我们对屏幕进行一次操作的时候,就会触发MotionEvent中的几个触摸事件:
ACTION_DOWN:手指刚刚触摸到屏幕时触发的事件;
ACTION_MOVE:手指在屏幕上移动的时候触发的事件;
ACTION_UP:手指从屏幕上抬起的一瞬间触发的事件。
  因此,对于我们常常做的一些操作,相应的事件触发顺序如下:
点击屏幕后立刻抬起手指:DOWN -> UP
滑动屏幕:DOWN -> MOVE -> … -> MOVE -> UP
  使用MotionEvent类,我们还可以获取到触摸屏幕时View的一些位置参数:
x:当前触摸的位置相对于目标View的X轴坐标,通过getX()方法获取;
y:当前触摸的位置相对于目标View的Y轴坐标,通过getY()方法获取;
rawX:当前触摸的位置相对于屏幕最左边的X轴坐标,通过getRawX()方法获取;
rawY:当前触摸的位置相对于屏幕最上边的Y轴坐标,通过getRawY()方法获取。

View相对屏幕的距离计算

这里说的View相对屏幕的距离,是指View的左上角相对于手机屏幕左上角的坐标。可以使用以下几个方法获取:
getLocationInWindow(),这个方法的用法代码如下:

int[] position = new int[2];
view.getLocationInWindow(position);
System.out.println("(" + position[0] + "," + position[1] + ")");

如果当前Activity是普通的Activity,则用这个方法得到的position数组中的第二个参数(Y坐标值)表示可见的状态栏的高度 + 可见的标题栏的高度 + View上端到标题栏下端的距离;
  如果当前Activity是对话框式的Activity,则Y坐标值表示可见的标题栏的高度 + View上端到标题栏下端的距离。
  注意:这里的“可见”表示的是能看到的,如果一个Activity中的状态栏或标题栏被隐藏了,则其高度用0表示。
getLocationOnScreen(),这个方法的用法代码如下:

int[] position = new int[2];
view.getLocationOnScreen(position);
System.out.println("(" + position[0] + "," + position[1] + ")");

getGlobalVisibleRect(),这个方法的用法代码如下:

Rect rect = new Rect();//Rect在这里的作用是“套住”这个View
view.getGlobalVisibleRect(rect);
System.out.println("(" + rect.left + "," + rect.top + ")");
一般自定义View的步骤
一、自定义View的属性
1、了解资源目录下书写资源属性的格式
2、了解在自定义view构造方法中获取自定义的属性的方式
二、重写onMesure方法
单一view:

只需要测量自己的尺寸,并setMeasuredDimension就可以了。由于View有默认的实现,所以如果自定义View使用了wrap_content,那么在onMeasure中就要调用
setMeasuredDimension,来指定view的宽高。如果使用的match_parent或者一个具体的dp值。那么直接使用super.onMeasure即可。

ViewGroup:

需要测量子View的尺寸。第一步先获取子View对象:getChildCount()得到子view的个数,再循环遍历,getChildAt(int index)拿子view。 第二步测量子VIew: 使用子view自身的测量方法 childView.measure(int wSpec, int hSpec);或者使用viewGroup的测量子view的方法:
measureChild(subView, int wSpec, int hSpec); 测量某一个子view,多宽,多高, 内部加上了viewGroup的padding值。
measureChildren(int wSpec, int hSpec); 测量所有子view 都是 多宽,多高, 内部调用了measureChild方法。
measureChildWithMargins(subView, intwSpec, int wUsed, int hSpec, int hUsed);
测量某一个子view,多宽,多高, 内部加上了viewGroup的padding值、margin值和传入的宽高wUsed、hUsed。

细节:

①getMeasuredWidth方法获得的值是setMeasuredDimension方法设置的值,它的值在measure方法运行后就会确定
②getWidth方法获得是layout方法中传递的四个参数中的mRight-mLeft,它的值是在layout方法运行后确定的

③一般情况下在onLayout方法中使用getMeasuredWidth方法,而在除onLayout方法之外的地方用getWidth方法。


三、重写onDraw方法
了解android2D绘画基础使用,包括canvas、paint类等。
实现更丰富的效果-View滑动
View滑动的辅助类
TouchSlop

TouchSlop是一个常量,和设备有关,不同设备上值可能不同,它是系统所能识别出来的被认为是滑动最小距离。这个常量有什么意义?当我们在处理滑动时,可以利用它来做一些过滤,比如当两个滑动事件的距离小于这个值,我们就可以认为未达到滑动距离的临界值,认为不是一次滑动,用户体验更好。

VelocityTracker

速度追踪,用于追踪手指在滑动过程中的速度,包括水平和垂直方向的。使用步骤如下:
1、先在View的onTouchEvent方法追踪当前单击事件的速度

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

2、计算速度,然后获取速度值。

velocityTracker.computeCurrentVelocity(1000);//指1000ms手指在水平方向从左到右滑动100像素,那么水平速度是100.从右往左则是-100.
int xVelocity=(int)velocityTracker.getXVelocity();
int yVelocity=(int)velocityTracker.getYVelocity();

3、重置和回收内存

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

手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。使用步骤如下:
1、先创建一个GestureDetector对象并实现OnGestureListener接口,根据需要还可以试下OnDoubleTapListener从而监听双击行为:

GestureDetector mGestureDetector=new GestureDetector(this);
//解决长按屏幕后无法拖动的现象
mGestureDetector.setIsLongpressEnabled(false);

2、接管目标View的onTouchEvent方法,在该方法中添加如下实现:

boolean consume=mGestureDetector.onTouchEvent(event);
return consume;

3、最后可以有选择的实现OnGestureListener和OnDoubleTapListener中的方法了。

Scroller

弹性滑动对象,用于实现View的弹性滑动。因为View的scrollTo/scrollBy方法滑动是瞬间完成的,而使用Scroller配合上View的computeScroll方法就可以实现有过渡效果的滑动。

View滑动的六种方式


六种滑动的方法,分别是:layout()、offsetLeftAndRight()与offsetTopAndBottom()、LayoutParams、动画、scollTo与scollBy和Scroller。

layout()

view进行绘制的时候会调用onLayout()方法来设置显示的位置,因此我们同样也可以通过修改View的left、top、right、bottom这四种属性来控制View的坐标。首先我们要自定义一个View,在onTouchEvent()方法中获取触摸点的坐标:

public boolean onTouchEvent(MotionEvent event) {
        //获取到手指处的横坐标和纵坐标
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;

...

接下来我们在ACTION_MOVE事件中计算偏移量,再调用layout()方法重新放置这个自定义View的位置就好了:
完整代码如下:

case MotionEvent.ACTION_MOVE:
                //计算移动的距离
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //调用layout方法来重新放置它的位置
                layout(getLeft()+offsetX, getTop()+offsetY,
                        getRight()+offsetX , getBottom()+offsetY);
                break;

当我们每次移动时都会调用layout()方法来对自己重新布局,从而达到移动View的效果。

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

public class CustomView extends View {
    private int lastX;
    private int lastY;

    public CustomView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
    public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomView(Context context) {
        super(context);
    }

    public boolean onTouchEvent(MotionEvent event) {
        //获取到手指处的横坐标和纵坐标
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                break;

            case MotionEvent.ACTION_MOVE:
                //计算移动的距离
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //调用layout方法来重新放置它的位置
                layout(getLeft()+offsetX, getTop()+offsetY,
                        getRight()+offsetX , getBottom()+offsetY);
                break;
        }

        return true;
    }
}
offsetLeftAndRight()与offsetTopAndBottom()

这两种方法和layout()方法效果方法差不多,使用也差不多,我们将ACTION_MOVE中的代码替换成如下代码:

case MotionEvent.ACTION_MOVE:
                //计算移动的距离
                int offsetX = x - lastX;
                int offsetY = y - lastY;
                //对left和right进行偏移
                offsetLeftAndRight(offsetX);
                //对top和bottom进行偏移
                offsetTopAndBottom(offsetY);
                break;
LayoutParams(改变布局参数)

LayoutParams主要保存了一个View的布局参数,因此我们可以通过LayoutParams来改变View的布局的参数从而达到了改变View的位置的效果。同样的我们将ACTION_MOVE中的代码替换成如下代码:

LinearLayout.LayoutParams layoutParams= (LinearLayout.LayoutParams) getLayoutParams();
                layoutParams.leftMargin = getLeft() + offsetX;
                layoutParams.topMargin = getTop() + offsetY;
                setLayoutParams(layoutParams);

因为父控件是LinearLayout,所以我们用了LinearLayout.LayoutParams,如果父控件是RelativeLayout则要使用RelativeLayout.LayoutParams。除了使用布局的LayoutParams外,我们还可以用ViewGroup.MarginLayoutParams来实现:

ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
                layoutParams.leftMargin = getLeft() + offsetX;
                layoutParams.topMargin = getTop() + offsetY;
                setLayoutParams(layoutParams);
动画

可以采用View动画来移动,在res目录新建anim文件夹并创建translate.xml:

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

在Java代码中引用:

mCustomView.setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));

当然使用属性动画移动那就更简单了,我们让CustomView在1000毫秒内沿着X轴像右平移300像素:

ObjectAnimator.ofFloat(mCustomView,"translationX",0,300).setDuration(1000).start();
scollTo与scollBy

scollTo(x,y)表示移动到一个具体的坐标点,而scollBy(dx,dy)则表示移动的增量为dx、dy。其中scollBy最终也是要调用scollTo的。scollTo、scollBy移动的是View的内容,如果在ViewGroup中使用则是移动他所有的子View。我们将ACTION_MOVE中的代码替换成如下代码:

((View)getParent()).scrollBy(-offsetX,-offsetY);

这里要实现CustomView随着我们手指移动的效果的话,我们就需要将偏移量设置为负值。 另外,要注意View内部的两个属性mScrollX和mScrollY的改变规则,这两个属性可通过getScrollX和getScrollY获得。简单记忆下:值永远等于View原始左上角位置和View移动后的左上角位置,正负与安卓坐标系相反即可。

Scroller

我们用scollTo/scollBy方法来进行滑动时,这个过程是瞬间完成的,所以用户体验不大好。这里我们可以使用Scroller来实现有过度效果的滑动,这个过程不是瞬间完成的,而是在一定的时间间隔完成的。Scroller本身是不能实现View的滑动的,它需要配合View的computeScroll()方法才能弹性滑动的效果。
在这里我们实现CustomView平滑的向右移动。
首先我们要初始化Scroller:

public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mScroller = new Scroller(context);
    }

接下来重写computeScroll()方法,系统会在绘制View的时候在draw()方法中调用该方法,这个方法中我们调用父类的scrollTo()方法并通过Scroller来不断获取当前的滚动值,每滑动一小段距离我们就调用invalidate()方法不断的进行重绘,重绘就会调用computeScroll()方法,这样我们就通过不断的移动一个小的距离并连贯起来就实现了平滑移动的效果:

@Override
    public void computeScroll() {
        super.computeScroll();
        if(mScroller.computeScrollOffset()){
            ((View) getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
             //通过不断的重绘不断的调用computeScroll方法
             invalidate();
        }  
    }

调用Scroller.startScroll()方法。我们在CustomView中写一个smoothScrollTo()方法,调用Scroller.startScroll()方法,在2000毫秒内沿X轴平移delta像素

public void smoothScrollTo(int destX,int destY){
        int scrollX=getScrollX();
        int delta=destX-scrollX;
        //1000秒内滑向destX
        mScroller.startScroll(scrollX,0,delta,0,2000);
        invalidate();
    }

最后我们在ViewSlideActivity.java中调用CustomView的smoothScrollTo()方法:

//使用Scroll来进行平滑移动
          mCustomView.smoothScrollTo(-400,0);

这里我们是设定CustomView沿着X轴向右平移400像素。

参考:
《Android开发艺术探索》