自定义ViewGroup原来如此简单?手把手带你写一个流式布局!

​ Android开发中,总会遇到这样和那样的需求。虽然官方已经给咱们提供了丰富的ViewGroupView的实现,可是总有无法知足需求的时候。这个时候咱们该怎么办呢? 首先遇事不决能够先Google一下,看看有无现成的轮子。若是有轮子,那么恭喜,扒来改改就好啦。若是没有轮子,那能咋办,只能本身造轮子咯。其实使用轮子更多时候是追求稳定和节约时间,咱们仍是须要对轮子的原理有必定的了解的。java

流式布局在Android开发中使用的场景应该仍是比较多的,好比标签展现搜索历史记录展现等等。这种样式的布局Android目前是没有原生的ViewGroup的,固然你要找轮子确定也是很容易找到的,不过今天我仍是想以自定义ViewGroup的方式来实现这么一个容器。web

什么是ViewGroup

​ 首先咱们得弄清楚ViewGroup是什么,还有它的职责。markdown

image-20201227155705094

ViewGroup继承自View,并实现了ViewManagerViewParent接口。按照官方的定义,ViewGroup是一个特别的View,它能够容纳其余的View,它实现了一系列添加和删除View的方法。同时ViewGroup还定义了LayoutParamsLayoutParams会影响ViewViewGroup的位置和大小相关属性。app

ViewGroup也是个抽象类,须要咱们重写onLayout方法,固然仅仅重写这么一个方法是不够的。ViewGroup自己只是实现了容纳View能力,实现一个ViewGroup咱们须要完成对自身的测量、对child的测量、child的布局等一系列的操做。ide

onMeasure

​ 这是自定义View实现的一个很是重要的方法,无论咱们是自定义View也好,仍是自定义ViewGroup都须要实现它。这个方法来自于ViewViewGroup自己没有去处理这个方法。这个方法会传递两个参数,分别是widthMeasureSpecheightMeasureSpec。这两个数值实际上是个混合的信息,他们包含了具体的宽高数值和宽高的模式。这里须要说一下MeasureSpecoop

MeasureSpec

MeasureSpecView的内部类,他是父容器给孩子传递的布局信息的一个压缩体。上文提到的传递的数值,实际上是经过MeasureSpecmakeMeasureSpec方法生成的:布局

public static class MeasureSpec {
        private static final int MODE_SHIFT = 30;
        private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

        @IntDef({UNSPECIFIED, EXACTLY, AT_MOST})
        @Retention(RetentionPolicy.SOURCE)
        public @interface MeasureSpecMode {}

        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(@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);
            }
        }
  //...
复制代码

​ 其实MeasureSpec表明一个32位的int值,高2位表示SpecMode,低30位表示SpecSize,咱们能够分别经过getModegetSize获取对应的信息。表示什么信息算是搞清楚了,那么这些信息又是如何确认的呢?this

​ 在ViewGroup中有个getChildMeasureSpec方法,这个方法的实现基本能够解答咱们的疑问google

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);
    int size = Math.max(0, specSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    switch (specMode) {
    // Parent has imposed an exact size on us
    case MeasureSpec.EXACTLY:
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size. So be it.
            resultSize = size;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;
    // Parent has imposed a maximum size on us
    case MeasureSpec.AT_MOST:
        if (childDimension >= 0) {
            // Child wants a specific size... so be it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size, but our size is not fixed.
            // Constrain child to not be bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;
    // Parent asked to see how big we want to be
    case MeasureSpec.UNSPECIFIED:
        if (childDimension >= 0) {
            // Child wants a specific size... let him have it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size... find out how big it should
            // be
            resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
            resultMode = MeasureSpec.UNSPECIFIED;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size.... find out how
            // big it should be
            resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
        break;
    }
    //noinspection ResourceType
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
复制代码

​ 代码长度仍是有点长,可是逻辑并不复杂。spec参数为ViewGroup的相关信息,padding则为ViewGroup的leftPadding+rightPadding+childLeftMargin+childRightMargin+usedWidth,childDimension为child的LayoutParams中指定的宽高信息。spa

​ child的具体的MeasureSpec会受到父容器的影响,也和自身的布局信息有关,具体以下:

  • 若是child的LayoutParams指定了固定的宽高,如100dp,则最终onMeasure被传递的size就是指定的宽高,mode则是MeasureSpec.EXACTLY
  • 若是child的宽高信息为MATCH_PARENT,这时候传递的size一般为父容器的宽高,mode则会和父容器的mode保持一致。
  • 若是child的宽高信息为WRAP_CONTENT,这时候传递的size也同样是父容器的宽高,若是父容器的mode是MeasureSpec.UNSPECIFIED,则传递的mode是MeasureSpec.UNSPECIFIED,不然为MeasureSpec.AT_MOST。

​ 这个specMode,简单的来讲EXACTLY就表明宽高信息是比较确认的,AT_MOST则是会告诉你一个最大宽度,实际宽度由你本身确认,UNSPECIFIED也是会告诉你一个父容器宽度,你也能够设置为任意高度。

onMeasure方法里应该作什么

​ 上面说了一堆关于MeasureSpec的,如今再来讲一下onMeasure方法里应该作什么。

​ 若是是自定义View,咱们须要根据父容器传递的MeasureSpec来确认自身的宽高。若是是MeasureMode是EXACTLY,则这个View的宽高就是传递过来的size,若是是AT_MOST和UNSPECIFIED,则须要咱们自行处理了。在咱们计算获得了一个想要的宽高信息后,须要调用setMeasuredDimension的方法来保存信息。

​ 若是是自定义ViewGroup,那咱们须要作的事情可能就要多一点了,首先咱们也仍是同样,须要确认ViewGroup自身的宽高信息,若是都是EXACTLY拿很好办,直接设置对应的size便可。若是想要支持WRAP_CONTENT,这时候可能就会比较麻烦一点了。首先咱们得想好一点,这个ViewGroup是如何为child布局的。这很重要,由于不一样的布局方式,child的排布不一样,都会影响实际占用的空间。

​ 仍是以LinearLayout举例吧,LinearLayout支持横向排列和纵向排列,他们须要执行的测量逻辑都是不同的。若是是纵向排列,则须要遍历child,测量child,并累加他们的高度和margin,最后还要加上自身高度,这样累加出来的数值就是WRAP_CONTENT下,自身应该占用的高度。若是是横向排列,则须要遍历和累加child,并累加他们的宽度和margin等,原理都是差很少的。

​ 总结一下,onMeasure方法须要ViewGroup结合父容器传递的MeasureSpec测量child,配合child的排布方式,确认自身的宽高

onLayout

onLayout方法传递了5个参数,changed表示自身的位置或大小是否发生了改变,剩下的分别为left,top,right,bottom,决定了他在父容器的位置。这是一个相对坐标,起点并非屏幕的左上角。

​ 那在这个方法里咱们应该作什么呢?若是是自定义View的时候,咱们能够不用管这个方法。由于View自己没有容纳child的能力,若是是ViewGroup,这时候咱们就须要为child执行布局操做了。咱们须要遍历child,执行它们的layout方法。经过调用layout方法,咱们能够传递left,top,right,bottom,肯定child在ViewGroup中的位置。一样的,这也是一个相对坐标,是依赖于父容器的。

​ 事实上,onLayout方法是在自身的layout方法被调用后调用的。Android总体的布局体系自上而下一层层的调用,传递布局信息,最终确认了各个View在屏幕上的位置。

onDraw

​ 一般来讲,自定义ViewGroup并不须要重写这个方法。这个方法用来作一些绘制操做,若是是自定义View,那咱们则须要重写这个方法,实现一些绘制逻辑。

Padding和Margin

​ 这两个概念仍是要说一下,理解一下它们的做用和实现原理。

  • Padding是相对于自身而言的,它影响了自身的绘制和child的布局,是View自身的属性。若是须要让这个属性生效,在绘制和布局时候,咱们须要基于这个属性的数值作必定的偏移,在测量的时候,咱们也须要考虑它的数值,为最终测量结果添加上。
  • Margin是相对于父容器而言的,它影响了ViewViewGroup中的布局,它一般是由LayoutParams所定义的。有这个属性的时候,咱们在测量时候须要考虑到它,而且累加上,在布局的时候,须要根据响应的属性,进行必定的偏移。

实现一个流式布局

​ 道理都理清楚了,写代码就会简单不少了。流式布局大概的效果就是添加的VIew按一行或者一列有序排列,若是一行或者一列放不下了,则换到下一行排列。下面就简单实现一个流式布局来加深一下理解。

​ 首先须要定义一个类,继承自ViewGroup:

public class FlowLayout extends ViewGroup {
    public FlowLayout(Context context) {
        this(context, null);
    }

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

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
      //todo 实现测量逻辑
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
			//todo 实现child的布局逻辑
    }
}
复制代码

​ 由于咱们须要支持margin属性,因此咱们还须要这样一个LayoutParamsViewGroup中已经定义了这样一个MarginLayoutParams,咱们建立一个内部类,继承此类实现:

public static class LayoutParams extends ViewGroup.MarginLayoutParams {
    public LayoutParams(Context c, AttributeSet attrs) {
        super(c, attrs);
    }

    public LayoutParams(int width, int height) {
        super(width, height);
    }

    public LayoutParams(ViewGroup.LayoutParams source) {
        super(source);
    }
}
复制代码

LayoutParams中还能够本身去定义一些个性化的布局参数,这里就简单处理了。同时咱们还得注意如下几个方法:

/** * 直接调用 {@link #addView(View view)}的时候 用来生成默认的LayoutParams * * @return */
@Override
protected LayoutParams generateDefaultLayoutParams() {
    return new LayoutParams(-2, -2);
}

/** * {@link #addView(View child, ViewGroup.LayoutParams params)}时候,用来检查布局参数是否正确 * * @param p * @return */
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
    return p instanceof LayoutParams;
}

/** * 若是{@link #checkLayoutParams(ViewGroup.LayoutParams p)}返回false,会调用此方法生成LayoutParams * * @param p * @return */
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
    if (p == null) {
        return generateDefaultLayoutParams();
    }
    return new LayoutParams(p);
}

/** * 若是xml中的child,会调用此方法生成布局参数 * @param attrs * @return */
@Override
public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new LayoutParams(getContext(), attrs);
}
复制代码

​ 注释我都写了,主要是用来用户addView时候的默认布局信息生成和检测,若是没处理好,可能会引发崩溃啥的。

​ 接下来是测量方法:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    Log.d(TAG, "onMeasure");
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    if (widthMode == MeasureSpec.EXACTLY) {
        //横向宽度固定
        int lineMaxHeight = 0;//当前行最高的行高
        int currentLeft = getPaddingLeft();//当前child的起点left
        int currentTop = getPaddingTop();//当前child的起点top
      	//去除paddingLeft 和 paddingRight即为可用宽度
        int availableWidth = widthSize - getPaddingLeft() - getPaddingRight();
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) {//gone的child 不处理
                continue;
            }
            //测量child
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
            int decoratedWidth = getDecoratedWidth(child);
            int decoratedHeight = getDecoratedHeight(child);
            if (currentLeft + decoratedWidth > availableWidth) {
                //宽度超了 换行
                currentLeft = decoratedWidth + getPaddingLeft();
                currentTop += lineMaxHeight;//高度加上以前的最大高度
                lineMaxHeight = decoratedHeight;
            } else {
                //若是不须要换行 只记录当前的最大高度。
                currentLeft += decoratedWidth;
                lineMaxHeight = Math.max(lineMaxHeight, decoratedHeight);
            }
            if (i == getChildCount() - 1) {
                //最后一个元素了 咱们须要累加高度
                currentTop += lineMaxHeight;
            }
        }
      	//保存宽高信息
        setMeasuredDimension(widthSize, currentTop + getPaddingBottom());
    } else if (heightMode == MeasureSpec.EXACTLY) {
        //todo 实现纵向固定的流式布局

    } else {
        //todo 实现宽高都固定的流式布局
        
    }
}
复制代码

​ 测量逻辑并不复杂,首先判断ViewGroup的宽高模式,这里实现了宽度固定的流式布局的处理逻辑。咱们须要遍历全部的child,并调用测量方法肯定他们的宽高。同时要注意的是child若是不可见则须要跳过。由于宽度是固定的,因此咱们须要计算出自身的高度。getDecoratedWidth获取的是child自身的宽度与自身的左右的margin的和。遍历过程当中依此排列child,若是一行排不下了,则执行换行逻辑,并累加高度,最后得出高度,保存。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    Log.d(TAG, "onLayout l :" + l + " t :" + t + " r :" + r + " b :" + b);
    int lineMaxHeight = 0;
    int currentLeft = getPaddingLeft();//当前child的起点left
    int currentTop = getPaddingTop();//当前child的起点top
    int availableWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == GONE) {//gone的child 不处理
            continue;
        }
        int decoratedWidth = getDecoratedWidth(child);
        int decoratedHeight = getDecoratedHeight(child);
        LayoutParams layoutParams = (LayoutParams) child.getLayoutParams();
        int childLeft, childTop;
        if (currentLeft + decoratedWidth > availableWidth) {
            //宽度超了 换行
            currentLeft = decoratedWidth + getPaddingLeft();
            currentTop += lineMaxHeight;//高度加上以前的最大高度
            lineMaxHeight = decoratedHeight;
            childLeft = getPaddingLeft() + +layoutParams.leftMargin;
            childTop = currentTop + layoutParams.topMargin;
        } else {
            //若是不须要换行 只记录当前的最大高度。
            childLeft = currentLeft + layoutParams.leftMargin;
            childTop = currentTop + layoutParams.topMargin;
            currentLeft += decoratedWidth;
            lineMaxHeight = Math.max(lineMaxHeight, decoratedHeight);
        }
        child.layout(childLeft, childTop,
                childLeft + child.getMeasuredWidth(), childTop + child.getMeasuredHeight());
    }
}
复制代码

​ onLayout方法里我也只是实现了宽度固定下的逻辑。逻辑和测量时候的思路同样,在测量的时候咱们已经为每一个child确认了自身的宽高,在这里咱们就只须要调用layout方法为每一个child执行布局逻辑便可。

​ 最后上运行效果,由于是demo因此样式比较随意,不要在乎这些细节(#^.^#)image-20201227182155002

自定义ViewGroup大体的流程就是这样了,若是还有什么困惑还不解能够留言,我会用心解答。

相关文章
相关标签/搜索