一个FlowLayout带你学会自定义ViewGroup

时间过得真快,又到了写博客的时候了(/▽╲)。这次按照计划记录一个简单的自定义ViewGroup:流布局FlowLayout的实现过程,将自定义控件知识储备-View的绘制流程和自定义控件知识储备-LayoutParams的那些事里的知识点结合起来,付诸实践。

1. 前言

早在学习Java的Swing基础知识的时候,就见到过里面的流布局FlowLayout,基本的效果就是让加入此容器的控件自左往右依次排列,如果当前行的宽度不足以容纳下一个控件,就会将此控件放置到下一行。其实这也跟css里向左浮动的效果很相似。

在Android的世界里,系统是没有提供类似FlowLayout布局的容器的。当然了,现在官方给我们提供了更强大也更复杂的FlexLayout了。不过嘛,本篇博客是总结一个自定义ViewGroup的实现流程,所以需要找一个难易适中的实例来进行分析,也就是FlowLayout了。(是的,我就是挑软柿子捏︿( ̄︶ ̄)︿)。

2. 效果

闲话少说,还是先来看看蘑菇君写的FlowLayout的功能:

  • 支持最基本的从左至右的排序,空间不足则换行
  • 支持设置子控件间的水平和竖直的间隔(也可以通过给每个child设置margin来实现,不过没有统一设置来的方便)
  • 支持绘制行之间的分割线
  • 支持FlowLayout本身的Gravity和child views的Gravity
  • 处理好FlowLayout的padding和child views的margin

这些都是FlowLayout基本的功能,效果如下图所示:

一个FlowLayout带你学会自定义ViewGroup_第1张图片
FlowLayout效果展示

是不是感觉还行?至少一般的情况下是能满足大部分人的需求滴。o( ̄▽ ̄)d

3. 分析

列举一下自定义ViewGroup的流程:

  1. 自定义属性:如果ViewGroup需要用到自定义属性,则需要声明、设置、解析并获取自定义属性值。
  2. 测量:在onMeasure方法里处理AT_MOSTEXACTLY两种测量模式下ViewGroup的宽高和children的宽高。(UNSPECIFIED模式可以暂不考虑)
  3. 布局:在onLayout方法里确定children的位置。
  4. 绘制:如果ViewGroup里需要绘制,则重写onDraw方法,按逻辑绘制。比如FlowLayout可以在每一行之间绘制一条分隔线。
  5. 处理LayoutParams:如果要为children定义布局属性,如layout_gravity,则需要自定义LayoutParams,并且重写ViewGroup相关的方法。
  6. 处理滑动事件:在本FlowLayout里暂时用不上...( ╯▽╰)

上面的步骤可能有所遗漏,不过也差不多啦。下面蘑菇君要根据上述的流程来一步一步的分析FlowLayout的源码,源码可能有点长,有些细节上的逻辑看不懂也莫方,只要了解流程对应的实现方式和注意事项就好,有兴趣的话可以稍后自己下载源码分析具体的逻辑实现。

好滴,那就让我们来一步一步的看,这个FlowLayout是如何在我手里...被玩残的...

3.1 自定义属性

3.1.1 声明属性

首先,自定义属性的第一步当然是声明属性,而最常使用的方式当然是在xml资源文件里(一般来说就是attrs.xml文件)声明需要使用的属性:

   
        
        
        
        
        
    

    
        
    

这里需要注意两个地方:

  1. 我们声明了两个declare-styleable,一个是为FlowLayout自身设置自定义属性;另一个是为孩子们提供额外属性,需要在自定义的LayoutParams里解析获取属性值。

  2. 大家都知道,我们在xml布局文件里使用自定义属性时,需要引入命名空间

xmlns:app="http://schemas.android.com/apk/res-auto"

使用自定义属性时,需要加上前缀app(或者是其它命名,只要一一对应)。但是有时候啊,我们自定义的属性名已经在系统中存在了,而且语义与我们想要的也很符合,比如如andrioid:textandroid:gravity等等。这个时候估计谁都会有一种“拿来主义”的冲动:直接使用系统里已经存在的属性名就好了嘛,多“原生”!既然有这种“邪恶”的需求,那Google工程师自然是要满足滴(~ ̄▽ ̄)~。

gravity属性为例,我们只要在declare-styleable里直接写上即可,不过这里要注意的是不需要也不能再加上format属性,加上format属性就代表着这是在声明一个新的属性,不加则代表这是在使用已存在的一个属性。

3.1.2 使用属性

使用属性就比较简单了:


3.1.3 解析并获取属性

在xml设置了相应的属性后,就需要在FlowLayout里解析并获取属性值了:


public static final int DEFAULT_SPACING = 8;
    public static final int DEFAULT_DIVIDER_COLOR = Color.parseColor("#ececec");
    public static final int DEFAULT_DIVIDER_WIDTH = 3;

    private int mGravity = (isIcs() ? Gravity.START : Gravity.LEFT) | Gravity.TOP;

    private int mVerticalSpacing; //vertical spacing
    private int mHorizontalSpacing; //horizontal spacing
    private int mDividerColor;
    private int mDividerWidth;
    
private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {

        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout, defStyleAttr, defStyleRes);

        try {
            mHorizontalSpacing = (int) ta.getDimension(R.styleable.FlowLayout_horizonSpacing, DEFAULT_SPACING);
            mVerticalSpacing = (int) ta.getDimension(R.styleable.FlowLayout_verticalSpacing, DEFAULT_SPACING);
            mDividerWidth = (int) ta.getDimension(R.styleable.FlowLayout_dividerWidth, DEFAULT_DIVIDER_WIDTH);
            mDividerColor = ta.getColor(R.styleable.FlowLayout_dividerColor, DEFAULT_DIVIDER_COLOR);
            int index = ta.getInt(R.styleable.FlowLayout_android_gravity, -1);
            if (index > 0) {
                setGravity(index);
            }
            initPaint();
        } finally {
            ta.recycle();
        }
        setWillNotDraw(false);

    }

一般来说,我们的自定义属性都得给个默认值,大家都这么懒,不能强人所难对不对。这默认值可以通过常量直接写在自定义类里,如上述代码所示。也可以写在xml资源文件里,提供给别人统一修改。

其次呢,英明神武的蘑菇君自然也得提供方法让别人方便的通过代码去动态修改这些属性啦(真不要脸~~( ﹁ ﹁ ) ~~~):

 public void setHorizontalSpacing(int pixelSize) {
        mHorizontalSpacing = pixelSize;
        requestLayout();
    }

    public void setVerticalSpacing(int pixelSize) {
        mVerticalSpacing = pixelSize;
        requestLayout();
    }

    public void setDividerColor(@ColorInt int color) {
        mDividerColor = color;
        mDividerPaint.setColor(color);
        invalidate();
    }
    ...

关于自定义属性的一些详细知识可以参考文章: Android 深入理解Android中的自定义属性

3.2 测量

在自定义ViewGroup时,测量流程一般是所有流程中最为复杂的一环。因为我们不仅要测量ViewGroup自身的尺寸,还得测量所有孩子的尺寸。而ViewGroup和孩子们之间的尺寸又是相互影响的。

如下图所示,在我们的FlowLayout里,当宽的测量模式为AT_MOST(比如FlowLayout的布局属性android:layout_widthwrap_content时),FlowLayout的测量宽度应该是所有行里最长的那一行的宽度,在下图中就是第二行的宽度。而当高的测量模式为AT_MOST,FlowLayout的测量高度应该是所有行的高度总和。

而对于child view来说,也有个小小的限制:当FlowLayout的layout_heightwrap_content,而child的layout_heightmatch_parent时,我希望child的测量高为它所处那一行的高度,而不是整个FlowLayout的高度或者是wrap_content。这也挺合情合理的吧,比如下图中第一行的child 再见这群坑比layout_heightmatch_parent,所以它就和第一行的高度一样高。

一个FlowLayout带你学会自定义ViewGroup_第2张图片
宽高为wrap_content时的FlowLayout

可能说得大家都有点晕了X﹏X,还是来一起看看onMeasure方法的源码吧:

 //保存所有child view
private final List> mLines = new ArrayList<>();
//保存所有行高
private final List mLineHeights = new ArrayList<>();
//保存所有行宽
private final List mLineWidths = new ArrayList<>();

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        mLines.clear();
        mLineHeights.clear();
        mLineWidths.clear();

        int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
        int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
        int modeHeight = MeasureSpec.getMode(heightMeasureSpec);

        int widthUsed = getPaddingLeft() + getPaddingRight() + mHorizontalSpacing;
        int lineWidth = widthUsed;
        int lineHeight = 0;

        int childCount = getChildCount();
        List lineViews = new ArrayList<>();
        
        for (int i = 0; i < childCount; i++) {

            View child = getChildAt(i);

            if (child.getVisibility() == View.GONE) {
                continue;
            }

            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            //测量每个child的宽高,每个child可用的最大宽高为sizeWidth-spacing-padding-margin
            measureChildWithMargins(child, widthMeasureSpec, mHorizontalSpacing * 2, heightMeasureSpec, mVerticalSpacing * 2);

            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            //判断这一行是否还能容下这个child
            if (lineWidth + childWidth + mHorizontalSpacing > sizeWidth) {
                //需要换行,则记录这一行的宽度,高度,下一行的初始宽度,初始高度
            
                mLineWidths.add(lineWidth);
                lineWidth = widthUsed + childWidth + mHorizontalSpacing;

                mLineHeights.add(lineHeight);
                lineHeight = childHeight;

                mLines.add(lineViews);
                lineViews = new ArrayList<>();
            } else {//容得下,则累加这一行的宽度,记录这一行的高度
                lineWidth += childWidth + mHorizontalSpacing;
                lineHeight = Math.max(lineHeight, childHeight);
            }

            lineViews.add(child);

        }
        //最后一行的处理
        mLineHeights.add(lineHeight);
        mLineWidths.add(lineWidth);
        mLines.add(lineViews);

        int maxWidth = Collections.max(mLineWidths);

        processChildHeights();//计算所有行的累积高度
        int totalHeight = getChildHeights();

        //TODO 处理getMinimumWidth/height的情况

        //设置自身的测量宽高
        setMeasuredDimension(
                (modeWidth == MeasureSpec.EXACTLY) ? sizeWidth : Math.min(maxWidth, sizeWidth),
                (modeHeight == MeasureSpec.EXACTLY) ? sizeHeight : Math.min(totalHeight, sizeHeight));
                
        //重新测量child的lp.height为MATCH_PARENT时的child的尺寸
        remeasureChild(widthMeasureSpec);
    }



上面的代码逻辑都有注释,相信大家都能理清大概的逻辑。暂时没理解也没关系,稍后自己去看代码再加上自己的思考肯定能看懂滴。(蘑菇君自我感觉脑子转的算慢的,看Github上的FlowLayout源码花了蛮久时间才弄懂大概逻辑,自己画图呀,运行demo呀,弄懂了以后,才开始自己动手写自己的FlowLayout...(๑•̀ㅂ•́)و✧)

这里要特别注意的是对children的测量过程。在上面的代码中,我使用了ViewGroup类里提供的measureChildWithMargins方法去测量每个child,对这个方法的具体剖析,可以去看自定义控件知识储备-View的绘制流程,这篇文章讲的很详细。但在上文中有提到过,我们对child有个限制:

当child的layout_heightmatch_parent时,child的测量高为它所处那一行的高度,而不是整个FlowLayout的高度或者是wrap_content

但是这个child所处那一行的高度是那一行所有child的高度的最大值,所以只有在完成这一行所有child的测量后,才知道这一行的高度是多少。所以上面的要求无法满足呀!我在测量该child的高度的时候,还不知道这一行的高度是多少啊!

一个FlowLayout带你学会自定义ViewGroup_第3张图片
这就尴尬了

该怎么办呢?其实也简单,既然当时测量某child的时候还不知道那一行的高度,那就在第一次所有child都测量完成后,再对那些layout_heightmatch_parent的child测量一遍就好啦。所以在上面onMeasure方法里的最后调用了remeasureChild这个方法去重新测量一遍child:

private void remeasureChild(int parentWidthSpec) {
        int numLines = mLines.size();
        for (int i = 0; i < numLines; i++) {//遍历每一行
            int lineHeight = mLineHeights.get(i);
            List lineViews = mLines.get(i);
            int children = lineViews.size();
            for (int j = 0; j < children; j++) {
                View child = lineViews.get(j);
                LayoutParams lp = (LayoutParams) child.getLayoutParams();
                if (lp.height == LayoutParams.MATCH_PARENT) {//对高为match_parent的child进行处理
                    if (child.getVisibility() == View.GONE) {
                        continue;
                    }

                    int widthUsed = lp.leftMargin + lp.rightMargin +
                            getPaddingLeft() + getPaddingRight() + 2 * mHorizontalSpacing;
                    //再次调用child的measure方法进行测量        
                    child.measure(
                            getChildMeasureSpec(parentWidthSpec, widthUsed, lp.width),
                            MeasureSpec.makeMeasureSpec(lineHeight - lp.topMargin - lp.bottomMargin, MeasureSpec.EXACTLY)
                    );
                }
            }
        }
    }

从这里我们也看得出来,一个View的onMeasure方法是很有可能被调用多次来确定最终的测量宽高的,所以下次遇到打印日志里或者断点调试下发现 onMeasure方法多次运行,莫要方呀o(‾◡◝)。

3.3 布局

布局过程呢,就稍微简单一些,因为我们在onMeasure方法里已经将所有child的宽高和位于哪一行等信息都计算好了,只要遍历children调用它们的layout方法放置好它们就行。不过这里有点麻烦的就是,我们需要支持FlowLayout自身的gravity属性和children的 gravity属性。那就得根据具体的gravity来计算相应的偏移量了,代码如下:

//根据gravity计算FlowLayout的垂直方向上的偏移量
private void processVerticalGravityMargin() {
        int verticalGravityMargin;
        int childHeights = getChildHeights();
        switch ((mGravity & Gravity.VERTICAL_GRAVITY_MASK)) {
            case Gravity.TOP://顶部
            default:
                verticalGravityMargin = 0;
                break;
            case Gravity.CENTER_VERTICAL://垂直居中
                verticalGravityMargin = Math.max((getHeight() - childHeights) / 2, 0);
                break;
            case Gravity.BOTTOM://底部
                verticalGravityMargin = Math.max(getHeight() - childHeights, 0);
                break;
        }
        mVerticalGravityMargin = verticalGravityMargin;
    }

//根据gravity计算FlowLayout的水平方向上的偏移量
    private void processHorizontalGravityMargins() {
        mLineMargins.clear();
        float horizontalGravityFactor;
        switch ((mGravity & Gravity.HORIZONTAL_GRAVITY_MASK)) {
            case Gravity.LEFT://水平靠左
            default:
                horizontalGravityFactor = 0;
                break;
            case Gravity.CENTER_HORIZONTAL://水平居中
                horizontalGravityFactor = .5f;
                break;
            case Gravity.RIGHT://水平靠右
                horizontalGravityFactor = 1;
                break;
        }

        int linesNum = mLineWidths.size();
        for (int i = 0; i < linesNum; i++) {
            int lineWidth = mLineWidths.get(i);
            mLineMargins.add((int) ((getWidth() - lineWidth) * horizontalGravityFactor) + getPaddingLeft() + mHorizontalSpacing);
        }
    }

给FlowLayout设置gravity的效果如下:

内容居中:

一个FlowLayout带你学会自定义ViewGroup_第4张图片
FlowLayout内容居中

内容在右下角:

一个FlowLayout带你学会自定义ViewGroup_第5张图片
FlowLayout内容在右下角

计算好了每行的偏移量后,layout方法的逻辑就很清晰了:

protected void onLayout(boolean changed, int l, int t, int r, int b) {

        processHorizontalGravityMargins();
        processVerticalGravityMargin();

        int numLines = mLines.size();
        int left;
        int top = getPaddingTop() + mVerticalSpacing + mVerticalGravityMargin;

        for (int i = 0; i < numLines; i++) {

            int lineHeight = mLineHeights.get(i);
            List lineViews = mLines.get(i);
            left = mLineMargins.get(i);
            int children = lineViews.size();

            for (int j = 0; j < children; j++) {

                View child = lineViews.get(j);

                if (child.getVisibility() == View.GONE) {
                    continue;
                }

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

                int childWidth = child.getMeasuredWidth();
                int childHeight = child.getMeasuredHeight();

                int gravityMargin = 0;
                //根据child的gravity计算child的相应偏移量
                if (Gravity.isVertical(lp.gravity)) {
                    switch (lp.gravity) {
                        case Gravity.TOP:
                        default:
                            gravityMargin = 0;
                            break;
                        case Gravity.CENTER_VERTICAL:
                        case Gravity.CENTER:
                            gravityMargin = (lineHeight - childHeight - lp.topMargin - lp.bottomMargin) / 2;
                            break;
                        case Gravity.BOTTOM:
                            gravityMargin = lineHeight - childHeight - lp.topMargin - lp.bottomMargin;
                            break;
                        //TODO 水平方向上可以支持gravity么?
                    }
                }

                child.layout(left + lp.leftMargin,
                        top + lp.topMargin + gravityMargin,
                        left + lp.leftMargin + childWidth,
                        top + lp.topMargin + gravityMargin + childHeight);

                Log.i(TAG, String.format("child (%d,%d) position: (%d,%d,%d,%d)",
                        i, j, child.getLeft(), child.getTop(), child.getRight(), child.getBottom()));

                left += childWidth + lp.leftMargin + lp.rightMargin + mHorizontalSpacing;

            }

            top += lineHeight + mVerticalSpacing;
        }

    }

3.4 绘制

本FlowLayout支持绘制分割线,这也是很容易的绘制,只要找准每条分割线的位置就行。不过万变不离其宗嘛,我现在能画一条线,下次就能画一个圆,再下次就能画个鸡蛋,再再下次我就能飞上天,画出太阳肩并肩...。咳咳,扯远了,我们还是来看看onDraw方法里的绘制逻辑:

@Override
    protected void onDraw(Canvas canvas) {

        int top = getPaddingTop() + mVerticalSpacing + mVerticalGravityMargin;
        int numLines = mLines.size();
        for (int i = 0; i < numLines; i++) {
            int lineHeight = mLineHeights.get(i);
            top += lineHeight + mVerticalSpacing;
            canvas.drawLine(getPaddingLeft(), top - mVerticalSpacing / 2, 
            getWidth() - getPaddingRight(), top - mVerticalSpacing / 2, mDividerPaint);
        }

    }

确实很简单,遍历每一行,在两行的中间根据配置的颜色和宽度画出一条线段即可。

不过这里要注意View的一个特殊方法:setWillNotDraw,来看一下这个方法的源码:

/**
     * If this view doesn't do any drawing on its own, set this flag to
     * allow further optimizations. By default, this flag is not set on
     * View, but could be set on some View subclasses such as ViewGroup.
     *
     * Typically, if you override {@link #onDraw(android.graphics.Canvas)}
     * you should clear this flag.
     *
     * @param willNotDraw whether or not this View draw on its own
     */
    public void setWillNotDraw(boolean willNotDraw) {
        setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);
    }

从这个方法的注释中可以看出,如果一个View不需要绘制任何内容,那么设置这个标记位为true后,系统会进行相应的优化。默认情况下,View没有启用这个优化标记位,而ViewGroup会默认启用这个标记位。

当我们的自定义ViewGroup需要通过重写onDraw来绘制内容时,我们需要显式地关闭WILL_NOT_DRAW这个标记位。

所以,在这个FlowLayout的构造方法里,我们可以调用setWillNotDraw(false)来进行优化。

3.5 处理LayoutParams

几乎每个自定义ViewGroup都得自定义自己的LayoutParams,来给children提供更好的服务。在本FlowLayout里,能给children带来的就是gravity属性的支持。来看看自定义的LayoutParams:

 public static class LayoutParams extends MarginLayoutParams {

        public int gravity = -1;

        public LayoutParams(Context c, AttributeSet attrs) {
            super(c, attrs);

            TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout);

            try {
                gravity = a.getInt(R.styleable.FlowLayout_Layout_android_layout_gravity, -1);
            } finally {
                a.recycle();
            }
        }

        public LayoutParams(int width, int height) {
            super(width, height);
            gravity = Gravity.TOP;
        }

        public LayoutParams(int width, int height, int gravity) {
            super(width, height);
            this.gravity = gravity;
        }

        public LayoutParams(ViewGroup.LayoutParams source) {
            super(source);
        }

    }

同时,FlowLayout还需要对以下几个方法进行重写:

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

    @Override
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return super.checkLayoutParams(p) && p instanceof LayoutParams;
    }

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

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

啥?不知道为啥要按上述代码那样做?那是时候去看看自定义控件知识储备-LayoutParams的那些事了。看完了你就大彻大悟,遁入......咳咳。

3. 展示

哎呀呀,这篇文章已经够长了,我就不贴资源文件,截图等东西啦,大家有需要的话,可以去Github上下载源码进行学习。

Github地址: https://github.com/yisizhu520/FlowLayout

PS:蘑菇君写的这个FlowLayout肯定还存在bug,而且我自己也知道几个不影响使用的小bug,但是我没有去改,等待有缘人去发现哈(≧∇≦)ノ。

也欢迎大家去提交issue和pull request,一起交流,一起进步。

4. 总结

终于写完这篇博客了,真是写死我了ค(TㅅT)。希望这篇文章除了能加深自己对自定义ViewGroup的理解外,还能帮助到大家。以前一直以为自己了解了自定义ViewGroup的一些知识,想要写一个容器控件出来应该不难的。然而,纸上得来终觉浅,当自己真的开始写的时候,发现满满的都是细节,满满的都是套路。比如在FlowLayout里的测量、布局、绘制都得考虑到间距的问题,什么margin啊,padding啊,spacing啊,都需要小心对待。不过,最终还是在不断的调试和修改中写出来了这个FlowLayout,想想还有点小激动呢!以后要做的应该就是不断的练习和总结,毕竟编程这件事,没啥好说的,just code it!

一个FlowLayout带你学会自定义ViewGroup_第6张图片
just code it

我是蘑菇君,我为自己带盐

5. 参考资料

  • 自定义控件知识储备-View的绘制流程
  • 自定义控件知识储备-LayoutParams的那些事
  • Android 深入理解Android中的自定义属性
  • blazsolar的Github开源项目:FlowLayout

你可能感兴趣的:(一个FlowLayout带你学会自定义ViewGroup)