View绘制(8)-自定义ViewGroup(一)之卡牌

作者: ZJ_Rocky | 来源:发表于2017-09-17 18:30 被阅读211次

    主目录见:Android高级进阶知识(这是总目录索引)
    这一篇主要是自定义ViewGroup的第一篇,所以会选一个简单的例子进行知识点的说明,看过前面的文章或者已经会了前面的知识,这一篇应该不是问题,我们就直接开始自定义ViewGroup之旅吧(ps:后面的例子还在寻找,暂未找到比较典型的例子,如果有什么好的可以推荐)。首先晒出我们今天的例子:

    WrapContent
    MatchParent

    一.目标

    因为这篇是对上面一篇《View和ViewGroup的绘制原理源码分析》知识点的回顾。所以我们的目标很明确:
    1.练习自定义ViewGroup怎么测量,布局,包括怎么绘制。
    2.了解自定义ViewGroup实现的几个构造函数作用。
    3.了解自定义属性。

    二.自定义ViewGroup

    1.构造函数

    我们知道,自定义ViewGroup会让实现构造函数,那么这几个构造函数都是干嘛的呢?首先我们说下这三个构造函数是什么情况下会被调用:
     1.public CardLayout(Context context) {}一个参数的构造函数的作用是程序内实例化该控件时调用。
     2.public CardLayout(Context context, AttributeSet attrs) {}两个参数的构造函数的作用是Layout文件实例化,会把xml里面的参数通过AttributeSet传进构造函数里面。
     3.public CardLayout(Context context, AttributeSet attrs, int defStyleAttr) {}三个参数的构造函数的第三个参数是可以携带一个主题的style信息。
    那么我们平常写是要怎么写呢?如果是直接继承的ViewGroup的话,建议是这么写:

     public CardLayout(Context context) {
            this(context,null);
        }
    
      public CardLayout(Context context, AttributeSet attrs) {
            this(context, attrs,0);
        }
    
      public CardLayout(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CardLayout);
            horizontalSpace = a.getDimensionPixelOffset(R.styleable.CardLayout_horizontal_space,DEFAULT_HORIZONTAL_SPACE);
            verticalSpace = a.getDimensionPixelOffset(R.styleable.CardLayout_vertical_space,DEFAULT_VERTICAL_PACE);
            a.recycle();
        }
    

    一个参数的构造函数调用两个参数的构造函数,两个参数的构造函数调用三个参数的构造函数。但是如果你是继承的已有的控件如ListView,TextView等,那么我们建议用下面这种方式写:

     public CardLayout(Context context) {
            super(context);
            init(context,null)
        }
    
      public CardLayout(Context context, AttributeSet attrs) {
            super(context, attrs);
            init(context,attrs);
        }
    
      public CardLayout(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init(context,attrs);
        }
    
    private void init(Context context,AttributeSet attrs){
           TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CardLayout);
            horizontalSpace = a.getDimensionPixelOffset(R.styleable.CardLayout_horizontal_space,DEFAULT_HORIZONTAL_SPACE);
            verticalSpace = a.getDimensionPixelOffset(R.styleable.CardLayout_vertical_space,DEFAULT_VERTICAL_PACE);
            a.recycle();
    }
    

    为什么呢?这是因为ListView,TextView内部构造函数会有一个默认的defStyleAttr,如果采用第一种方式写的话那么有可能会丢失这个defStyleAttr(因为两个参数的构造函数调用三个参数的构造函数的时候传进去的是零)。

    2.自定义属性

    自定义属性虽然比较简单,但是是大部分自定义ViewGroup必经的一部,如果想要详细了解自定义属性这里推荐一篇《Android 深入理解Android中的自定义属性 》,这篇讲的非常详细了。这里我们简单地定义:

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="CardLayout">
            <attr name="horizontal_space" format="dimension"></attr>
            <attr name="vertical_space" format="dimension"></attr>
        </declare-styleable>
    </resources>
    

    这个例子的水平间隔和垂直间隔是可以用户自定义的,因为他们是dp单位的,所以format="dimension"。

    3.onMeasure

    我们从上一篇《View和ViewGroup的绘制原理源码分析》知道,测量的时候会调用到ViewGroup的onMeasure方法,这个方法不是final的,我们可以重写。

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            int widthMode = MeasureSpec.getMode(widthMeasureSpec);
            int widthSize = MeasureSpec.getSize(widthMeasureSpec);
            int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    
            int width = 0;
            int height = 0;
    //        measureChildren(widthMeasureSpec,heightMeasureSpec);
            for (int i = 0; i < getChildCount();i++){
                final View child = getChildAt(i);
                if (child.getVisibility() != GONE) {
                    LayoutParams lp = child.getLayoutParams();
    
                    int childWidthSpec = getChildMeasureSpec(widthMeasureSpec,child.getPaddingLeft() + child.getPaddingRight() ,lp.width);
                    int childHeightSpec = getChildMeasureSpec(heightMeasureSpec,child.getPaddingTop() + child.getPaddingBottom(),lp.height);
    
                    child.measure(childWidthSpec,childHeightSpec);
    
                    width = Math.max(width,i * horizontalSpace + child.getMeasuredWidth());
                    height = Math.max(height,i * verticalSpace + child.getMeasuredHeight());
                }
            }
    
            setMeasuredDimension(MeasureSpec.EXACTLY == widthMode ? widthSize : width,
                    MeasureSpec.EXACTLY == heightMode ? heightSize : height);
        }
    

    首先我们看到第一个部分:

            int widthMode = MeasureSpec.getMode(widthMeasureSpec);
            int widthSize = MeasureSpec.getSize(widthMeasureSpec);
            int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    

    这个地方是获取ViewGroup推荐的计算宽和高以及相应的模式即父类测量完推荐该自定义控件的宽高,什么意思呢?我们首先应该知道我们的模式有三种:
    1.EXACTLY(精确模式):父容器能够计算出自己的大小,一般是设置为match_parent或者固定值的自定义控件。
    2.AT_MOST(至多不超过模式):父容器指定了一个大小, View 的大小不能大于这个值,也就是父容器不能够直接计算出自己的大小,需要先由它所有的子View自己去计算一下自己大小(measureChildren()),然后再去设置该自定义控件自己的大小(setMeasuredDimension)。一般是设置为wrap_content。
    3.UNSPECIFIED(不确定模式):父容器不对 view 有任何限制,要多大给多大,多见于ListView、GridView等。
    我们这里的widthMeasureSpec或者heightMeasureSpec其实是mode+size的32位数,高两位为mode,后面30位为size的值即控件的宽或者高。如果我们View要自己手动合成widthMeasureSpec或者heightMeasureSpec我们可以调用makeMeasureSpec方法,具体实现如下:

        public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
                                              @MeasureSpecMode int mode) {
                if (sUseBrokenMakeMeasureSpec) {
                    return size + mode;
                } else {
                    return (size & ~MODE_MASK) | (mode & MODE_MASK);
                }
            }
    

    所以我们这里得到父控件给我们测量的宽高和模式。然后我们继续看代码:

           for (int i = 0; i < getChildCount();i++){
                final View child = getChildAt(i);
                if (child.getVisibility() != GONE) {
                    LayoutParams lp = child.getLayoutParams();
    
                    int childWidthSpec = getChildMeasureSpec(widthMeasureSpec,child.getPaddingLeft() + child.getPaddingRight() ,lp.width);
                    int childHeightSpec = getChildMeasureSpec(heightMeasureSpec,child.getPaddingTop() + child.getPaddingBottom(),lp.height);
    
                    child.measure(childWidthSpec,childHeightSpec);
    
                    width = Math.max(width,i * horizontalSpace + child.getMeasuredWidth());
                    height = Math.max(height,i * verticalSpace + child.getMeasuredHeight());
                }
            }
    

    我们看到我们这里会遍历出来所有的子View,因为ViewGroup下面会有多个View,所以这个地方我们遍历出来分别测量,首先我们判断一下View是不是设置了visibility="gone"如果设置了那就不会测量了,不然就先调用getChildMeasureSpec()方法,这个方法是根据子View的padding和width以及父类给的widthMeasureSpec来获得子类的childWidthSpec(子view的mode+size值),高度跟宽度类似,然后调用child.measure(childWidthSpec,childHeightSpec)方法进行测量子视图。ViewGroup还提供了简单的测量子视图的方法:measureChildren,measureChildWithMargins具体里面做了什么跟我们上面写的代码有点像。接着我们看最后一句:

    setMeasuredDimension(MeasureSpec.EXACTLY == widthMode ? widthSize : width,
                    MeasureSpec.EXACTLY == heightMode ? heightSize : height);
    

    这个地方我们看到我们判断了widthMode,heightMode看是哪一种方式,如果是EXACTLY说明父容器能测量出来我们的宽高即自定义控件layout_xxx设置了match_parent或者是精确值,我们就把父容器给我们的宽高设置给我们的自定义控件即可。如果我们的模式是AT_MOST或者UNSPECIFIED,其实UNSPECIFIED的场景比较少,主要是考虑AT_MOST即wrap_content的情况,wrap_content的情况我们父容器不知道我们自定义控件的宽高,我们需要根据自定义控件的子View的宽高然后来判断自定义控件的宽高。

    4.onLayout

    这个方法主要是布局自定义控件中子view的位置。我们这个自定义控件的布局代码如下:

    @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            if (changed){
                for (int i = 0;i < getChildCount();i++){
                    final View child = getChildAt(i);
                    if (child.getVisibility() != GONE) {
                        int leftSpace = horizontalSpace * i;
                        int topSpace = verticalSpace * i;
    
                        child.layout(leftSpace,topSpace,leftSpace + child.getMeasuredWidth(),topSpace + child.getMeasuredHeight());
                    }
                }
            }
        }
    

    我们看到我们这边是遍历自定义控件下面所有子view,然后分别调用child.layout()方法,这个方法里面的参数主要是左,上,右,下四个值,即左上角和右下角的坐标值。

    5.自定义LayoutParams

    除了上面的两个主要方法,我们有时还会自定义LayoutParams,我们会override下面几个方法:

        @Override
        protected LayoutParams generateDefaultLayoutParams() {
            return new CustomLayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.WRAP_CONTENT);
        }
    
        @Override
        public LayoutParams generateLayoutParams(AttributeSet attrs) {
            return new CustomLayoutParams(getContext(),attrs);
        }
    
        @Override
        protected LayoutParams generateLayoutParams(LayoutParams p) {
            return new CustomLayoutParams(p.width,p.height);
        }
    
        @Override
        protected boolean checkLayoutParams(LayoutParams p) {
            return p instanceof CustomLayoutParams;
        }
    

    我们看到这几个是要实现的方法,generateDefaultLayoutParams(),generateLayoutParams(),generateLayoutParams()方法主要是返回默认的LayoutParams和自定义的LayoutParams,checkLayoutParams()这个方法主要是检查下是否是我们自定义的LayoutParams。那么这个LayoutParams到底怎么自定义呢?首先我们要自定义一个属性:

     <declare-styleable name="CardLayout_LayoutParams">  
            <attr name="vertical_spacing" format="dimension"/>  
        </declare-styleable>
    

    然后我们要继承ViewGroup.LayoutParams类实现:

      public static class CustomLayoutParams extends ViewGroup.LayoutParams{
            private int verSpacing;
    
            public CustomLayoutParams(Context context, AttributeSet attrs) {
                super(context, attrs);
    
                TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CardLayout_LayoutParams);
                 try {
                     verSpacing = a.getDimensionPixelSize(R.styleable.CardLayout_vertical_space,-1);
                } finally {
                    a.recycle();
                }
            }
    
            public CustomLayoutParams(int width, int height) {
                super(width, height);
            }
        }
    

    我们可以看到我们这里面自定义了一个属性verSpacing。那么我们到底要怎么使用这个自定义的LayoutParams呢?在布局里面我们可以修改成下面这样:

      <TextView
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:text="第二张卡牌"
            app:vertical_spacing="100dp"
            android:padding="25dp"
            android:background="@color/colorAccent"/>
    

    这里面我们看到跟自定义属性用法一样 app:vertical_spacing="100dp",然后我们修改onMeasure的测量方法:

       for (int i = 0; i < getChildCount();i++){
                final View child = getChildAt(i);
                if (child.getVisibility() != GONE) {
                    CustomLayoutParams lp = (CustomLayoutParams) child.getLayoutParams();
    
                    int childWidthSpec = getChildMeasureSpec(widthMeasureSpec,child.getPaddingLeft() + child.getPaddingRight(),lp.width);
                    int childHeightSpec = getChildMeasureSpec(heightMeasureSpec,child.getPaddingTop() + child.getPaddingBottom(),lp.height);
    
                    child.measure(childWidthSpec,childHeightSpec);
    
                    width = Math.max(width,i * horizontalSpace + child.getMeasuredWidth());
                    if (lp.verSpacing != -1 && i >= 1){
                        height = Math.max(height,lp.verSpacing + (i-1)*verticalSpace + child.getMeasuredHeight());
                    }else{
                        height = Math.max(height,i * verticalSpace + child.getMeasuredHeight());
                    }
                }
            }
    

    我们看到我们会获取到每个子view的CustomLayoutParams ,然后获取这个属性verSpacing 的值,然后就可以进行使用了。我们的onLayout也要修改:

      for (int i = 0;i < getChildCount();i++){
                    final View child = getChildAt(i);
                    if (child.getVisibility() != GONE) {
                        CustomLayoutParams lp = (CustomLayoutParams) child.getLayoutParams();
                        int leftSpace = horizontalSpace * i;
                        int topSpace = verticalSpace * i;
                        if (lp.verSpacing != -1 && i >= 1){
                            topSpace = lp.verSpacing + (i-1)*verticalSpace;
                        }
    
                        child.layout(leftSpace,topSpace,leftSpace + child.getMeasuredWidth(),topSpace + child.getMeasuredHeight());
                    }
                }
    

    在布局的时候我们添加进这个CustomLayoutParams 里面这个verSpacing 的影响。所以我们最后的效果变成了这样:

    自定义LayoutParams
    到这里我们大概自定义的ViewGroup已经讲解完毕,但是不是还有onDraw方法呢?为什么这里没有了,其实onDraw方法ViewGroup是默认不调用的,因为这个方法是绘制的子View,只有在有背景或者设置了setWillNotDraw(false)的情况下才会调用,但是自定义ViewGroup会调用dispatchDraw方法,这个方法是绘制ViewGroup下面的子View,但是这个方法一般很少使用。所以这里我们的自定义ViewGroup讲到这里已经完毕。
    如果你想要这篇文档的源代码。那么请[重击下载]源代码。

    总结:这是我们自定义ViewGroup的第一个实例,后面我们希望能有复杂点但是典型的例子,如果你有什么推荐,可以留言哈,一起把知识说明完整哈。

    我是吃辣条长大的

    相关文章

      网友评论

        本文标题:View绘制(8)-自定义ViewGroup(一)之卡牌

        本文链接:https://www.haomeiwen.com/subject/umnpsxtx.html