三、自定义View,看完这篇就够了

0.56字数 3976阅读 232

本文参考以下文章和书籍,总结梳理、实操后整理为这篇学习笔记:
https://www.jianshu.com/p/c84693096e41
https://blog.csdn.net/harvic880925/article/details/47029169
https://www.jianshu.com/p/146e5cec4863
https://www.jianshu.com/p/1dab927b2f36
https://www.jianshu.com/p/158736a2549d
https://www.jianshu.com/p/95afeb7c8335
https://www.jianshu.com/p/e9d8420b1b9c

对于多View的视图,结构是树形结构:最顶层是ViewGroup,ViewGroup下可能有多个ViewGroup或View,如下图:


view1.png

一定要记住:无论是measure过程、layout过程还是draw过程,永远都是从View树的根节点开始测量或计算(即从树的顶端开始),一层一层、一个分支一个分支地进行(即树形递归),最终计算整个View树中各个View,最终确定整个View树的相关属性。

一、坐标基础知识

1、Android坐标系

Android的坐标系定义为:屏幕的左上角为坐标原点,向右为x轴正方向,向下为y轴正方向。


屏幕坐标系.png
两者坐标系区别.png

2、View位置(坐标)描述

View的位置由4个顶点决定的(如下A、B、C、D)


View的位置描述.png

4个顶点的位置描述分别由4个值决定:
(请记住:View的位置是相对于父控件而言的)

  • Top:子View上边界到父view上边界的距离
  • Left:子View左边界到父view左边界的距离
  • Bottom:子View下边距到父View上边界的距离
  • Right:子View右边界到父view左边界的距离
View的位置描述2.png

3、位置获取方式

View的位置是通过view.getxxx()函数进行获取

getLeft();      //获取子View左上角距父View左侧的距离
getTop();    //获取子View左上角距父View顶部的距离
getRight();     //获取子View右下角距父View左侧的距离
getBottom();    //获取子View右下角距父View顶部的距离

与MotionEvent中 get()和getRaw()的区别

//get() :触摸点相对于其所在组件坐标系的坐标
 event.getX();       
 event.getY();

//getRaw() :触摸点相对于屏幕默认坐标系的坐标
 event.getRawX();    
 event.getRawY();
get() 和 getRaw() 的区别.png

4、Android的角度(angle)与弧度(radian)

角度和弧度区别.png

在默认的屏幕坐标系中角度正方向为顺时针


屏幕坐标系角度正方向.png

二、View视图基础知识

自定义View我们一般只需重写两个函数:onMeasure()onDraw()

onMeasure() 负责对当前View的尺寸进行测量
onDraw() 负责把当前这个View绘制出来

自定义ViewGroup绘制流程分为三步:测量、布局、绘制。我们一般需重写三个函数:onMeasure()onLayout()onDraw()

onMeasure():测量自己的大小,为正式布局提供建议。(注意,只是建议,至于用不用,要看onLayout);
onLayout():使用layout()函数对所有子控件布局;
onDraw():根据布局的位置绘图;

1、onMeasure()

我们自定义的View,首先得要测量宽高尺寸。为什么要测量宽高尺寸?我在刚学自定义View的时候非常无法理解!因为我当时觉得,我在xml文件中已经指定好了宽高尺寸了,我自定义View中有必要再次获取宽高并设置宽高吗?既然我自定义的View是继承自View类,google团队直接在View类中直接把xml设置的宽高获取,并且设置进去不就好了吗?那google为啥让我们做这样的“重复工作”呢?
在xml布局文件中,我们的layout_width和layout_height参数可以不用写具体的尺寸,而是wrap_content或者是match_parent。其意思我们都知道,就是将尺寸设置为“包住内容”和“填充父布局给我们的所有空间”。这两个设置并没有指定真正的大小,可是我们绘制到屏幕上的View必须是要有具体的宽高的,正是因为这个原因,我们必须自己去处理和设置尺寸。当然了,View类给了默认的处理,但是如果View类的默认处理不满足我们的要求,我们就得重写onMeasure函数啦。

先看看onMeasure函数原型:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) 

参数中的widthMeasureSpec和heightMeasureSpec是个什么鬼?看起来很像width和height,没错,这两个参数就是包含宽和高的信息。什么?包含?难道还要其他信息?是的!它还包含测量模式,也就是说,一个int整数,里面放了测量模式和尺寸大小。那么一个数怎么放两个信息呢?我们知道,我们在设置宽高时有3个选择:wrap_content、match_parent以及指定固定尺寸,而测量模式也有3种:UNSPECIFIED,EXACTLY,AT_MOST,当然,他们并不是一一对应关系哈,但测量模式无非就是这3种情况,而如果使用二进制,我们只需要使用2个bit就可以做到,因为2个bit取值范围是[0,3]里面可以存放4个数足够我们用了。那么Google是怎么把一个int同时放测量模式和尺寸信息呢?我们知道int型数据占用32个bit,而google实现的是,将int数据的前面2个bit用于区分不同的布局模式,后面30个bit存放的是尺寸的数据。

1.1 MeasureSpec

测量规格(MeasureSpec) = 测量模式(mode) + 测量大小(size)


测量规格.png

三种测量模式(Mode):UNSPECIFIED、EXACTLY 和
AT_MOST。


三种测量模式.png

那我们怎么从int数据中提取测量模式和尺寸呢?放心,不用你每次都要写一次移位<<和取且&操作,Android内置类MeasureSpec帮我们写好啦~,我们只需按照下面方法就可以拿到啦:

// 1. 获取测量模式(Mode)
    int specMode = MeasureSpec.getMode(measureSpec)

// 2. 获取测量大小(Size)
    int specSize = MeasureSpec.getSize(measureSpec)

// 3. 通过Mode 和 Size 生成新的SpecMode
    int measureSpec=MeasureSpec.makeMeasureSpec(size, mode);

MeasureSpec类的源码分析

    /**
     * MeasureSpec类的源码分析
     **/
    public class MeasureSpec {
        // 进位大小 = 2的30次方
        // int的大小为32位,所以进位30位 = 使用int的32和31位做标志位
        private static final int MODE_SHIFT = 30;

        // 运算遮罩:0x3为16进制,10进制为3,二进制为11
        // 3向左进位30 = 11 00000000000(11后跟30个0)  
        // 作用:用1标注需要的值,0标注不要的值。因1与任何数做与运算都得任何数、0与任何数做与运算都得0
        private static final int MODE_MASK = 0x3 << MODE_SHIFT;

        // UNSPECIFIED的模式设置:0向左进位30 = 00后跟30个0,即00 00000000000
        // 通过高2位
        public static final int UNSPECIFIED = 0 << MODE_SHIFT;

        // EXACTLY的模式设置:1向左进位30 = 01后跟30个0 ,即01 00000000000
        public static final int EXACTLY = 1 << MODE_SHIFT;

        // AT_MOST的模式设置:2向左进位30 = 10后跟30个0,即10 00000000000
        public static final int AT_MOST = 2 << MODE_SHIFT;

        /**
         * makeMeasureSpec()方法
         * 作用:根据提供的size和mode得到一个详细的测量结果吗,即measureSpec
         **/
        public static int makeMeasureSpec(int size, int mode) {

            return size + mode;
            // measureSpec = size + mode;此为二进制的加法 而不是十进制
            // 设计目的:使用一个32位的二进制数,其中:32和31位代表测量模式(mode)、后30位代表测量大小(size)
            // 例如size=100(4),mode=AT_MOST,则measureSpec=100+10000...00=10000..00100  
        }
        

        /**
         * getMode()方法
         * 作用:通过measureSpec获得测量模式(mode)
         **/
        public static int getMode(int measureSpec) {

            return (measureSpec & MODE_MASK);
            // 即:测量模式(mode) = measureSpec & MODE_MASK;  
            // MODE_MASK = 运算遮罩 = 11 00000000000(11后跟30个0)
            //原理:保留measureSpec的高2位(即测量模式)、使用0替换后30位
            // 例如10 00..00100 & 11 00..00(11后跟30个0) = 10 00..00(AT_MOST),这样就得到了mode的值
        }

        
        /**
         * getSize方法
         * 作用:通过measureSpec获得测量大小size
         **/
        public static int getSize(int measureSpec) {

            return (measureSpec & ~MODE_MASK);
            // size = measureSpec & ~MODE_MASK;  
            // 原理类似上面,即 将MODE_MASK取反,也就是变成了00 111111(00后跟30个1),将32,31替换成0也就是去掉mode,保留后30位的size  
        }
    }

1.2 MeasureSpec值的计算

上面讲了那么久MeasureSpec,那么MeasureSpec值到底是如何计算得来?
子View的MeasureSpec值根据子View的布局参数(LayoutParams)和父容器的MeasureSpec值计算得来的,具体计算逻辑封装在getChildMeasureSpec()里。


MeasureSpec值的计算.png

getChildMeasureSpec()的源码分析:

    ![示意图.png](https://upload-images.jianshu.io/upload_images/2255795-478a0436788e8bf6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
/**
     * 源码分析:getChildMeasureSpec()
     * 作用:根据父视图的MeasureSpec & 布局参数LayoutParams,计算单个子View的MeasureSpec
     * 注:子view的大小由父view的MeasureSpec值 和 子view的LayoutParams属性 共同决定
     *
     * @param spec           父view的详细测量值 (MeasureSpec)
     * @param padding        view当前尺寸的的内边距和外边距(padding, margin)
     * @param childDimension 子视图的布局参数(宽 / 高)
     **/

    public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        //父view的测量模式
        int specMode = MeasureSpec.getMode(spec);
        //父view的大小
        int specSize = MeasureSpec.getSize(spec);
        //通过父view计算出的子view = 父大小-边距(父要求的大小,但子view不一定用这个值)   
        int size = Math.max(0, specSize - padding);
        //子view想要的实际大小和模式(需要计算)  
        int resultSize = 0;
        int resultMode = 0;

        //通过父view的MeasureSpec和子view的LayoutParams确定子view的大小  
        // 当父view的模式为EXACITY时,父view强加给子view确切的值
        //一般是父view设置为match_parent或者固定值的ViewGroup 
        switch (specMode) {
            case MeasureSpec.EXACTLY:
                // 当子view的LayoutParams>0,即有确切的值  
                if (childDimension >= 0) {
                    //子view大小为子自身所赋的值,模式大小为EXACTLY  
                    resultSize = childDimension;
                    resultMode = MeasureSpec.EXACTLY;

                    // 当子view的LayoutParams为MATCH_PARENT时(-1)  
                } else if (childDimension == LayoutParams.MATCH_PARENT) {
                    //子view大小为父view大小,模式为EXACTLY  
                    resultSize = size;
                    resultMode = MeasureSpec.EXACTLY;

                    // 当子view的LayoutParams为WRAP_CONTENT时(-2)      
                } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                    //子view决定自己的大小,但最大不能超过父view,模式为AT_MOST  
                    resultSize = size;
                    resultMode = MeasureSpec.AT_MOST;
                }
                break;

            // 当父view的模式为AT_MOST时,父view强加给子view一个最大的值。(一般是父view设置为wrap_content)  
            case MeasureSpec.AT_MOST:
                // 道理同上  
                if (childDimension >= 0) {
                    resultSize = childDimension;
                    resultMode = MeasureSpec.EXACTLY;
                } else if (childDimension == LayoutParams.MATCH_PARENT) {
                    resultSize = size;
                    resultMode = MeasureSpec.AT_MOST;
                } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                    resultSize = size;
                    resultMode = MeasureSpec.AT_MOST;
                }
                break;

            // 当父view的模式为UNSPECIFIED时,父容器不对view有任何限制,要多大给多大
            // 多见于ListView、GridView  
            case MeasureSpec.UNSPECIFIED:
                if (childDimension >= 0) {
                    // 子view大小为子自身所赋的值  
                    resultSize = childDimension;
                    resultMode = MeasureSpec.EXACTLY;
                } else if (childDimension == LayoutParams.MATCH_PARENT) {
                    // 因为父view为UNSPECIFIED,所以MATCH_PARENT的话子类大小为0  
                    resultSize = 0;
                    resultMode = MeasureSpec.UNSPECIFIED;
                } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                    // 因为父view为UNSPECIFIED,所以WRAP_CONTENT的话子类大小为0  
                    resultSize = 0;
                    resultMode = MeasureSpec.UNSPECIFIED;
                }
                break;
        }
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }
示意图.png

2、onLayout()

onLayout()是实现所有子控件布局的函数。注意,是所有子控件!!!


layout过程.png

先讲讲在onLayout()中我们应该做什么。
我们先看看ViewGroup的onLayout()函数的默认行为是什么

@Override
protected abstract void onLayout(boolean changed, int l, int t, int r, int b);

是一个抽象方法,说明凡是派生自ViewGroup的类都必须自己去实现这个方法。像LinearLayout、RelativeLayout等布局,都是重写了这个方法,然后在内部按照各自的规则对子视图进行布局的。

源码分析:

    /**
     * 源码分析:layout()
     * 作用:确定View本身的位置,即设置View本身的四个顶点位置
     */
    public void layout(int l, int t, int r, int b) {

        // 当前视图的四个顶点
        int oldL = mLeft;
        int oldT = mTop;
        int oldB = mBottom;
        int oldR = mRight;

        // 1. 确定View的位置:setFrame() / setOpticalFrame()
        // 即初始化四个顶点的值、判断当前View大小和位置是否发生了变化 & 返回 
        // ->>分析1、分析2
        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

        // 2. 若视图的大小 & 位置发生变化
        // 会重新确定该View所有的子View在父容器的位置:onLayout()
        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {

            onLayout(changed, l, t, r, b);
            // 对于单一View的laytou过程:由于单一View是没有子View的,故onLayout()是一个空实现->>分析3
            // 对于ViewGroup的laytou过程:由于确定位置与具体布局有关,所以onLayout()在ViewGroup为1个抽象方法,需重写实现(后面会详细说)
            ...

        }
        
        
        /**
         * 分析1:setFrame()
         * 作用:根据传入的4个位置值,设置View本身的四个顶点位置
         * 即:最终确定View本身的位置
         */
        protected boolean setFrame ( int left, int top, int right, int bottom){
            ...
            // 通过以下赋值语句记录下了视图的位置信息,即确定View的四个顶点
            // 从而确定了视图的位置
            mLeft = left;
            mTop = top;
            mRight = right;
            mBottom = bottom;

            mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);
        }
        
        
        /**
         * 分析2:setOpticalFrame()
         * 作用:根据传入的4个位置值,设置View本身的四个顶点位置
         * 即:最终确定View本身的位置
         */
        private boolean setOpticalFrame ( int left, int top, int right, int bottom){

            Insets parentInsets = mParent instanceof View ?
                    ((View) mParent).getOpticalInsets() : Insets.NONE;

            Insets childInsets = getOpticalInsets();

            // 内部实际上是调用setFrame()
            return setFrame(
                    left + parentInsets.left - childInsets.left,
                    top + parentInsets.top - childInsets.top,
                    right + parentInsets.left + childInsets.right,
                    bottom + parentInsets.top + childInsets.bottom);
        }
        // 回到调用原处

        
        /**
         * 分析3:onLayout()
         * 注:对于单一View的laytou过程
         *    a. 由于单一View是没有子View的,故onLayout()是一个空实现
         *    b. 由于在layout()中已经对自身View进行了位置计算,所以单一View的layout过程在layout()后就已完成了
         */
        protected void onLayout ( boolean changed, int left, int top, int right, int bottom){

            // 参数说明
            // changed 当前View的大小和位置改变了 
            // left 左部位置
            // top 顶部位置
            // right 右部位置
            // bottom 底部位置
        }

3、onDraw()

draw过程.png

源码分析:

/**
  * 源码分析:draw()
  * 作用:根据给定的 Canvas 自动渲染 View(包括其所有子 View)。
  * 绘制过程:
  *   1. 绘制view背景
  *   2. 绘制view内容
  *   3. 绘制子View
  *   4. 绘制装饰(渐变框,滑动条等等)
  * 注:
  *    a. 在调用该方法之前必须要完成 layout 过程
  *    b. 所有的视图最终都是调用 View 的 draw ()绘制视图( ViewGroup 没有复写此方法)
  *    c. 在自定义View时,不应该复写该方法,而是复写 onDraw(Canvas) 方法进行绘制
  *    d. 若自定义的视图确实要复写该方法,那么需先调用 super.draw(canvas)完成系统的绘制,然后再进行自定义的绘制
  */ 
  public void draw(Canvas canvas) {

    ...// 仅贴出关键代码
  
    int saveCount;

    // 步骤1: 绘制本身View背景
        if (!dirtyOpaque) {
            drawBackground(canvas);
        }

    // 若有必要,则保存图层(还有一个复原图层)
    // 优化技巧:当不需绘制 Layer 时,“保存图层“和“复原图层“这两步会跳过
    // 因此在绘制时,节省 layer 可以提高绘制效率
    final int viewFlags = mViewFlags;
    if (!verticalEdges && !horizontalEdges) {

    // 步骤2:绘制本身View内容
        if (!dirtyOpaque) 
            onDraw(canvas);
        // View 中:默认为空实现,需复写
        // ViewGroup中:需复写

    // 步骤3:绘制子View
    // 由于单一View无子View,故View 中:默认为空实现
    // ViewGroup中:系统已经复写好对其子视图进行绘制我们不需要复写
        dispatchDraw(canvas);
        
    // 步骤4:绘制装饰,如滑动条、前景色等等
        onDrawScrollBars(canvas);

        return;
    }
    ...    
}

下面,我们继续分析在draw()中4个步骤调用的drawBackground()、 onDraw()、dispatchDraw()、onDrawScrollBars(canvas)

/**
  * 步骤1:drawBackground(canvas)
  * 作用:绘制View本身的背景
  */
  private void drawBackground(Canvas canvas) {
        // 获取背景 drawable
        final Drawable background = mBackground;
        if (background == null) {
            return;
        }
        // 根据在 layout 过程中获取的 View 的位置参数,来设置背景的边界
        setBackgroundBounds();

        .....

        // 获取 mScrollX 和 mScrollY值 
        final int scrollX = mScrollX;
        final int scrollY = mScrollY;
        if ((scrollX | scrollY) == 0) {
            background.draw(canvas);
        } else {
            // 若 mScrollX 和 mScrollY 有值,则对 canvas 的坐标进行偏移
            canvas.translate(scrollX, scrollY);


            // 调用 Drawable 的 draw 方法绘制背景
            background.draw(canvas);
            canvas.translate(-scrollX, -scrollY);
        }
   } 

/**
  * 步骤2:onDraw(canvas)
  * 作用:绘制View本身的内容
  * 注:
  *   a. 由于 View 的内容各不相同,所以该方法是一个空实现
  *   b. 在自定义绘制过程中,需由子类去实现复写该方法,从而绘制自身的内容
  *   c. 谨记:自定义View中 必须 且 只需复写onDraw()
  */
  protected void onDraw(Canvas canvas) {
      
        ... // 复写从而实现绘制逻辑

  }

/**
  * 步骤3: dispatchDraw(canvas)
  * 作用:绘制子View
  * 注:由于单一View中无子View,故为空实现
  */
  protected void dispatchDraw(Canvas canvas) {

        ... // 空实现

  }

/**
  * 步骤4: onDrawScrollBars(canvas)
  * 作用:绘制装饰,如 滚动指示器、滚动条、和前景等
  */
  public void onDrawForeground(Canvas canvas) {
        onDrawScrollIndicators(canvas);
        onDrawScrollBars(canvas);

        final Drawable foreground = mForegroundInfo != null ? mForegroundInfo.mDrawable : null;
        if (foreground != null) {
            if (mForegroundInfo.mBoundsChanged) {
                mForegroundInfo.mBoundsChanged = false;
                final Rect selfBounds = mForegroundInfo.mSelfBounds;
                final Rect overlayBounds = mForegroundInfo.mOverlayBounds;

                if (mForegroundInfo.mInsidePadding) {
                    selfBounds.set(0, 0, getWidth(), getHeight());
                } else {
                    selfBounds.set(getPaddingLeft(), getPaddingTop(),
                            getWidth() - getPaddingRight(), getHeight() - getPaddingBottom());
                }

                final int ld = getLayoutDirection();
                Gravity.apply(mForegroundInfo.mGravity, foreground.getIntrinsicWidth(),
                        foreground.getIntrinsicHeight(), selfBounds, overlayBounds, ld);
                foreground.setBounds(overlayBounds);
            }

            foreground.draw(canvas);
        }
    }

三、自定义View实例

假设我们要实现这样一个效果:将当前的View以红色正方形的形式显示,即要宽高相等,默认的宽高值为100像素,并且在这个正方形上绘制一个内切圆。

1、重写onMeasure()

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = getMySize(100, widthMeasureSpec);
        int height = getMySize(100, heightMeasureSpec);

        if (width < height) {
            height = width;
        } else {
            width = height;
        }

        setMeasuredDimension(width, height);
    }

    private int getMySize(int defaultSize, int measureSpec) {
        int mySize = defaultSize;

        int mode = MeasureSpec.getMode(measureSpec);
        int size = MeasureSpec.getSize(measureSpec);

        switch (mode) {
            case MeasureSpec.UNSPECIFIED: {//如果没有指定大小,就设置为默认大小
                mySize = defaultSize;
                break;
            }
            case MeasureSpec.AT_MOST: {//如果测量模式是最大取值为size
                //我们将大小取最大值,你也可以取其他值
                mySize = size;
                break;
            }
            case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改变它
                mySize = size;
                break;
            }
        }
        return mySize;
    }

2、重写onDraw()

上面我们我们已经把尺寸设定好了了,接下来就是把我们想要的效果画出来吧

    @Override
    protected void onDraw(Canvas canvas) {
        //调用父View的onDraw函数,因为View这个类帮我们实现了一些
        // 基本的而绘制功能,比如绘制背景颜色、背景图片等
        super.onDraw(canvas);
        int r = getMeasuredWidth() / 2;//也可以是getMeasuredHeight()/2,本例中我们已经将宽高设置相等了
        //圆心的横坐标为当前的View的左边起始位置+半径
        int centerX = getLeft() + r;
        //圆心的纵坐标为当前的View的顶部起始位置+半径
        int centerY = getTop() + r;

        Paint paint = new Paint();
        paint.setColor(Color.GREEN);
        //开始绘制
        canvas.drawCircle(centerX, centerY, r, paint);
    }

3、xml布局文件

    <com.itlaowang.customviewdemo.view.CustomView4
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#ff0000" />

此时效果如图:


效果图

4、自定义布局属性

如果有些属性我们希望由用户指定,只有当用户不指定的时候才用我们硬编码的值,比如上面的默认尺寸,我们想要由用户自己在布局文件里面指定该怎么做呢?那当然是通我们自定属性,让用户用我们定义的属性啦~
首先我们需要在res/values/styles.xml文件(如果没有请自己新建)里面声明一个我们自定义的属性:

    <!--name为声明的"属性集合"名,可以随便取,但是最好是设置为跟我们的View一样的名称-->
    <declare-styleable name="CustomView4">
        <!--声明我们的属性,名称为default_size,取值类型为尺寸类型(dp,px等)-->
        <attr name="default_size" format="dimension" />
    </declare-styleable>

接下来就是在布局文件用上我们的自定义的属性啦~

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:laowang="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:context=".CustomAct4">

    <com.itlaowang.customviewdemo.view.CustomView4
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#ff0000"
        laowang:default_size="100dp" />

</LinearLayout>

注意:需要在根标签(LinearLayout)里面设定命名空间,命名空间名称可以随便取,比如lawing,命名空间后面取得值是固定的:"http://schemas.android.com/apk/res-auto"
最后就是在我们的自定义的View里面把我们自定义的属性的值取出来,在构造函数中,还记得有个AttributeSet属性吗?就是靠它帮我们把布局里面的属性取出来:

    private int defalutSize;
    public CustomView4(Context context, AttributeSet attrs) {
        super(context, attrs);
        //第二个参数就是我们在styles.xml文件中的<declare-styleable>标签
        //即属性集合的标签,在R文件中名称为R.styleable+name
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomView4);

        //第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称
        //第二个参数为,如果没有设置这个属性,则设置的默认的值
        defalutSize = a.getDimensionPixelSize(R.styleable.CustomView4_default_size, 100);

        //最后记得将TypedArray对象回收
        a.recycle();
    }

最后,把MyView的完整代码附上:

public class CustomView4 extends View {

    private int defalutSize;

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

    public CustomView4(Context context, AttributeSet attrs) {
        super(context, attrs);
        //第二个参数就是我们在styles.xml文件中的<declare-styleable>标签
        //即属性集合的标签,在R文件中名称为R.styleable+name
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomView4);

        //第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称
        //第二个参数为,如果没有设置这个属性,则设置的默认的值
        defalutSize = a.getDimensionPixelSize(R.styleable.CustomView4_default_size, 100);

        //最后记得将TypedArray对象回收
        a.recycle();
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = getMySize(widthMeasureSpec);
        int height = getMySize(heightMeasureSpec);

        if (width < height) {
            height = width;
        } else {
            width = height;
        }

        setMeasuredDimension(width, height);
    }

    private int getMySize(int measureSpec) {
        int mySize = defalutSize;

        int mode = MeasureSpec.getMode(measureSpec);
        int size = MeasureSpec.getSize(measureSpec);

        switch (mode) {
            case MeasureSpec.UNSPECIFIED: {//如果没有指定大小,就设置为默认大小
                mySize = defalutSize;
                break;
            }
            case MeasureSpec.AT_MOST: {//如果测量模式是最大取值为size
                //我们将大小取最大值,你也可以取其他值
                mySize = size;
                break;
            }
            case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改变它
                mySize = size;
                break;
            }
        }
        return mySize;
    }


    @Override
    protected void onDraw(Canvas canvas) {
        //调用父View的onDraw函数,因为View这个类帮我们实现了一些
        // 基本的而绘制功能,比如绘制背景颜色、背景图片等
        super.onDraw(canvas);
        int r = getMeasuredWidth() / 2;//也可以是getMeasuredHeight()/2,本例中我们已经将宽高设置相等了
        //圆心的横坐标为当前的View的左边起始位置+半径
        int centerX = getLeft() + r;
        //圆心的纵坐标为当前的View的顶部起始位置+半径
        int centerY = getTop() + r;

        Paint paint = new Paint();
        paint.setColor(Color.GREEN);
        //开始绘制
        canvas.drawCircle(centerX, centerY, r, paint);
    }

}

四、自定义ViewGroup实例

自定义一个ViewGroup控件,效果如图:


效果图

这个效果图主要有两点:

  • 六个TextView竖直排列
  • 背景的Layout宽度是match_parent,高度是wrap_content.

下面我们就看一下,代码上如何实现:

1、XML布局

<?xml version="1.0" encoding="utf-8"?>
<com.itlaowang.customviewdemo.view.CustomView1 xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#ff00ff"
    tools:context=".CustomAct1">

    <TextView
        android:id="@+id/btn_cs1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView1" />

    <TextView
        android:id="@+id/btn_cs2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView2" />

    <TextView
        android:id="@+id/btn_cs3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView3" />

    <TextView
        android:id="@+id/btn_cs4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView4" />

    <TextView
        android:id="@+id/btn_cs5"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView5" />

    <TextView
        android:id="@+id/btn_cs6"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView6" />

</com.itlaowang.customviewdemo.view.CustomView1>

可见里面有六个TextView,然后自定义的MyLinLayout布局,宽度设为了match_parent,高度设为了wrap_content

2、重写onMeasure()函数

onMeasure()的作用就是根据container内部的子控件计算自己的宽和高,最后通过setMeasuredDimension(int width,int height设置进去)

public class CustomView1 extends ViewGroup {

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

    public CustomView1(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidthModel = MeasureSpec.getMode(widthMeasureSpec);
        int measureHeightModel = MeasureSpec.getMode(heightMeasureSpec);
        int measureWidthSize = MeasureSpec.getSize(widthMeasureSpec);
        int measureHeightSize = MeasureSpec.getSize(heightMeasureSpec);

        int width = 0;
        int height = 0;
        int count = getChildCount();

        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();
            width = Math.max(childWidth, width);
            height += childHeight;
        }

        setMeasuredDimension(measureWidthModel == MeasureSpec.EXACTLY ? measureWidthSize : width, measureHeightModel == MeasureSpec.EXACTLY ? measureHeightSize : height);
    }
}

3、重写onLayout()函数

在这部分,就是根据自己的意愿把内部的各个控件排列起来。我们要完成的是将所有的控件垂直排列

@Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int top = 0;
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();
            child.layout(0, top, childWidth, top + childHeight);
            top += childHeight;
        }
    }

4、getMeasuredWidth()与getWidth()

趁热打铁,就这个例子,我们讲一个很容易出错的问题:getMeasuredWidth()与getWidth()的区别。他们的值大部分时间都是相同的,但意义却是根本不一样的,我们就来简单分析一下。
区别主要体现在下面几点:

  • 首先getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到。
  • getMeasureWidth()方法中的值是通过setMeasuredDimension()方法来进行设置的,而getWidth()方法中的值则是通过layout(left,top,right,bottom)方法设置的。

还记得吗,我们前面讲过,setMeasuredDimension()提供的测量结果只是为布局提供建议,最终的取用与否要看layout()函数。大家再看看我们上面重写的MyLinLayout,是不是我们自己使用child.layout(left,top,right,bottom)来定义了各个子控件所应在的位置:

            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();
            child.layout(0, top, childWidth, top + childHeight);

从代码中可以看到,我们使用child.layout(0, top, childWidth, top + childHeight);来布局控件的位置,其中getWidth()的取值就是这里的右坐标减去左坐标的宽度;因为我们这里的宽度是直接使用的child.getMeasuredWidth()的值,当然会导致getMeasuredWidth()与getWidth()的值是一样的。如果我们在调用layout()的时候传进去的宽度值不与getMeasuredWidth()相同,那必然getMeasuredWidth()与getWidth()的值就不再一样了。
一定要注意的一点是:getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到。再重申一遍!!!!!

5、获取子控件Margin的方法

如果要自定义ViewGroup支持子控件的layout_margin参数,则自定义的ViewGroup类必须重载generateLayoutParams()函数,并且在该函数中返回一个ViewGroup.MarginLayoutParams派生类对象,这样才能使用margin参数。
我们在上面例子的基础上,添加上layout_margin参数。

5.1 首先,在XML中添加上layout_margin参数

<?xml version="1.0" encoding="utf-8"?>
<com.itlaowang.customviewdemo.view.CustomView1 xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#ff00ff"
    tools:context=".CustomAct1">

    <TextView
        android:id="@+id/btn_cs1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:background="#ff0000"
        android:text="TextView1" />

    <TextView
        android:id="@+id/btn_cs2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:background="#f0f0f0"
        android:text="TextView2" />

    <TextView
        android:id="@+id/btn_cs3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:background="#00ff00"
        android:text="TextView3" />

    <TextView
        android:id="@+id/btn_cs4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:background="#ffff00"
        android:text="TextView4" />

    <TextView
        android:id="@+id/btn_cs5"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:background="#00ffff"
        android:text="TextView5" />

    <TextView
        android:id="@+id/btn_cs6"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:background="#0000ff"
        android:text="TextView6" />

</com.itlaowang.customviewdemo.view.CustomView1>

我们在每个TextView中都添加了一layout_marginTop参数,而且值分别是10dp,20dp,30dp;背景也都分别改为了不同的颜色;
现在我们运行一上,看看效果:


效果图

从图中可以看到,根本没作用!!!这是为什么呢?因为测量和布局都是我们自己实现的,我们在onLayout()中没有根据Margin来布局,当然不会出现有关Margin的效果啦。需要特别注意的是,如果我们在onLayout()中根据margin来布局的话,那么我们在onMeasure()中计算container的大小时,也要加上margin,不然会导致container太小,而控件显示不全的问题。费话不多说,我们直接看代码实现。

5.2 重写generateLayoutParams()函数

    @Override
    protected LayoutParams generateLayoutParams(LayoutParams p) {
        return new MarginLayoutParams(p);
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

    @Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
    }

在这里,我们重写了两个函数,一个是generateLayoutParams()函数,一个是generateDefaultLayoutParams()函数。直接返回对应的MarginLayoutParams()的实例。至于为什么要这么写,我们后面再讲,这里先把Margin信息获取到再说。

5.3 重写onMeasure()

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidthModel = MeasureSpec.getMode(widthMeasureSpec);
        int measureHeightModel = MeasureSpec.getMode(heightMeasureSpec);
        int measureWidthSize = MeasureSpec.getSize(widthMeasureSpec);
        int measureHeightSize = MeasureSpec.getSize(heightMeasureSpec);

        int width = 0;
        int height = 0;
        int count = getChildCount();

        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            width = Math.max(childWidth, width);
            height += childHeight;
        }

        setMeasuredDimension(measureWidthModel == MeasureSpec.EXACTLY ? measureWidthSize : width, measureHeightModel == MeasureSpec.EXACTLY ? measureHeightSize : height);
    }

通过 child.getLayoutParams()获取child对应的LayoutParams实例,将其强转成MarginLayoutParams;
然后在计算childHeight时添加上顶部间距和底部间距。计算childWidth时添加上左边间距和右边间距。
也就是说,我们在计算宽度和高度时不仅考虑到子控件的本身的大小还要考虑到子控件间的间距问题。

5.4 重写onLayout()函数

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int top = 0;
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            child.layout(0, top, childWidth, top + childHeight);
            top += childHeight;
        }
    }

在这里同样在布局子控件时,添加上子控件间的间距,具体就不讲了,很容易理解。
最终的效果图如下:


效果图

5.5 原理

上面我们看了要重写generateDefaultLayoutParams()函数才能获取控件的margin间距。那为什么要重写呢?下面这句就为什么非要强转呢?

MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

下面我们来看看这么做的原因。
首先,在container在初始化子控件时,会调用LayoutParams generateLayoutParams(LayoutParams p)来为子控件生成对应的布局属性,但默认只是生成layout_width和layout_height所以对应的布局参数,即在正常情况下的generateLayoutParams()函数生成的LayoutParams实例是不能够取到margin值的。即:

/**
*从指定的XML中获取对应的layout_width和layout_height值
*/
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}
/*
*如果要使用默认的构造方法,就生成layout_width="wrap_content"、layout_height="wrap_content"对应的参数
*/
protected LayoutParams generateDefaultLayoutParams() {
     return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}

所以,如果我们还需要margin相关的参数就只能重写generateLayoutParams()函数了:

public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new MarginLayoutParams(getContext(), attrs);
}

由于generateLayoutParams()的返回值是LayoutParams实例,而MarginLayoutParams是派生自LayoutParam的;所以根据类的多态的特性,可以直接将此时的LayoutParams实例直接强转成MarginLayoutParams实例;
所以下面这句在这里是不会报错的:

MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

大家也可以为了安全起见利用instanceOf来做下判断,如下:

MarginLayoutParams lp = null
if (child.getLayoutParams() instanceof  MarginLayoutParams) {
    lp = (MarginLayoutParams) child.getLayoutParams();
    …………
}

所以整体来讲,就是利用了类的多态特性!下面来看看MarginLayoutParams和generateLayoutParams()都做了什么。

5.6 MarginLayoutParams与generateLayoutParams()的实现

首先,我们看看generateLayoutPararms()都做了什么吧,它是怎么得到布局值的:

//位于ViewGrop.java中
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}
public LayoutParams(Context c, AttributeSet attrs) {
    TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_Layout);
    setBaseAttributes(a,
            R.styleable.ViewGroup_Layout_layout_width,
            R.styleable.ViewGroup_Layout_layout_height);
    a.recycle();
}
protected void setBaseAttributes(TypedArray a, int widthAttr, int heightAttr) {
    width = a.getLayoutDimension(widthAttr, "layout_width");
    height = a.getLayoutDimension(heightAttr, "layout_height");
}

从上面的代码中明显可以看出,generateLayoutParams()调用LayoutParams()产生布局信息,而LayoutParams()最终调用setBaseAttributes()来获得对应的宽,高属性。
这里是通过TypedArray对自定义的XML进行值提取的过程,难度不大,不再细讲。从这里也可以看到,generateLayoutParams生成的LayoutParams属性只有layout_width和layout_height的属性值。

下面再来看看MarginLayoutParams的具体实现,其实通过上面的过程,大家也应该想到,它也是通过TypeArray来解析自定义属性来获得用户的定义值的(大家看到长代码不要害怕,先列出完整代码,下面会分段讲):

public MarginLayoutParams(Context c, AttributeSet attrs) {
    super();
 
    TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_MarginLayout);
    int margin = a.getDimensionPixelSize(
            com.android.internal.R.styleable.ViewGroup_MarginLayout_layout_margin, -1);
    if (margin >= 0) {
        leftMargin = margin;
        topMargin = margin;
        rightMargin= margin;
        bottomMargin = margin;
    } else {
       leftMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginLeft,
               UNDEFINED_MARGIN);
       rightMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginRight,
               UNDEFINED_MARGIN);
 
       topMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginTop,
               DEFAULT_MARGIN_RESOLVED);
 
       startMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginStart,
               DEFAULT_MARGIN_RELATIVE);
       endMargin = a.getDimensionPixelSize(
               R.styleable.ViewGroup_MarginLayout_layout_marginEnd,
               DEFAULT_MARGIN_RELATIVE);
    }
    a.recycle();
}

这段代码分为两部分:
第一部分:提取layout_margin的值并设置

TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_MarginLayout);
int margin = a.getDimensionPixelSize(
        com.android.internal.R.styleable.ViewGroup_MarginLayout_layout_margin, -1);
if (margin >= 0) {
    leftMargin = margin;
    topMargin = margin;
    rightMargin= margin;
    bottomMargin = margin;
} else {
  …………
}

在这段代码中就是通过提取layout_margin的值来设置上,下,左,右边距的。
第二部分:如果用户没有设置layout_margin,而是单个设置的,那么就一个个提取,代码如下:

leftMargin = a.getDimensionPixelSize(
        R.styleable.ViewGroup_MarginLayout_layout_marginLeft,
        UNDEFINED_MARGIN);
rightMargin = a.getDimensionPixelSize(
        R.styleable.ViewGroup_MarginLayout_layout_marginRight,
        UNDEFINED_MARGIN);
 
topMargin = a.getDimensionPixelSize(
        R.styleable.ViewGroup_MarginLayout_layout_marginTop,
        DEFAULT_MARGIN_RESOLVED);
 
startMargin = a.getDimensionPixelSize(
        R.styleable.ViewGroup_MarginLayout_layout_marginStart,
        DEFAULT_MARGIN_RELATIVE);
endMargin = a.getDimensionPixelSize(
        R.styleable.ViewGroup_MarginLayout_layout_marginEnd,
        DEFAULT_MARGIN_RELATIVE);

这里就是对layout_marginLeft、layout_marginRight、layout_marginTop、layout_marginBottom的值一个个提取的过程。难度不大,也没什么好讲的了。
从这里大家也可以看到为什么非要重写generateLayoutParams()函数了,就是因为默认的generateLayoutParams()函数只会提取layout_width、layout_height的值,只有MarginLayoutParams()才具有提取margin间距的功能!!!!