Android内部分享[12]——Android中自定义View和ViewGroup

View 和 ViewGroup 的关系

在开始自定义组件 View 和 ViewGroup 之前我们先来理解一下它们之间的关系,在 Android 中所有 UI 视图组件的根类是 View,而 ViewGroup 继承自 View,但是需要注意的是 ViewGroup 是一个抽象类.

android.view.View 类:

@UiThread
public class View implements Drawable.Callback, KeyEvent.Callback,
        AccessibilityEventSource {
    //...
}

android.view.ViewGroup 类:

@UiThread
public abstract class ViewGroup extends View implements ViewParent, ViewManager {

    protected ArrayList<View> mDisappearingChildren;

    //...
}

从上面代码可以看出来一个 ViewGroup 中可以包含多个 View,也就是说 ViewGroup 既是一个容器又是一个 View,这样就可以将 ViewGroup 也作为 View 添加到这个集合中了,于是就可以形成如下图所示的树状结构。

Android 视图树状结构关系

我们所有的基础组件都是基于 View 来实现的,也就是说都是继承自 View. 我们前面提到过的组件有: Button, TextView, EditText, ListView, CheckBox, RadioButton, Gallery, Spinner 和一些其他特殊组件 AutoCompleteTextView, ImageSwitcher, TextSwitcher. 还有我们接触过的容器组件:LinearLayout, FrameLayout, RelativeLayout.

如果上面列出的这些预定义的组件不能满足你的要求,你可以选择继承它们来做一些小调整,甚至你可以直接去实现 View 来自定义你自己的组件。

自定义 View

View 类一般用于绘图操作,需要重写它的 onDraw 方法,但它不可以包含其他组件,没有 addView(View view) 方法。接下来我们先来继承一个 View, 但是发现有这么多构造函数。

实现View的构造函数

我们应该选择哪个呢?这里建议选择有 AttributeSet 对象的构造函数,这是因为有此对象的构造函数允许布局编辑器创建和编辑视图的实例。

public class CustomeView extends View {

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

在绘制自定义视图时,最重要的一步是覆盖 onDraw() 方法。onDraw() 的参数是一个 Canvas 对象,视图可以使用它来绘制自身。Canvas 类定义了用于绘制文本、线条、位图和许多其他图形原语的方法。您可以在 onDraw() 中使用这些方法来创建自定义用户界面 (UI)。例如绘制一个椭圆:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.drawOval(mRect, mPaint);
}

这里需要创建一个画笔和一个矩形框(用于标定椭圆的大小),我们就创建一个公用的画笔即可(而且一般情况也只需要创建一个画笔):

private RectF mRect = new RectF();
private Paint mPaint = new Paint();

在构造函数中对画笔 Paint 和 RectF 进行初始化:

public CustomeView(Context context, AttributeSet attrs) {
    super(context, attrs);
    mRect.set(0, 0, 100, 100);
    mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
    mPaint.setAntiAlias(true); //去锯齿
    mPaint.setColor(Color.BLUE);
}

这样我们就已经自定义了一个最简单的 View 了,可以把它引入到布局文件中去显示出来了。我们顺便看一下如何将一个自定义的 View 拖放到约束布局 ConstraintLayout 中,如下图选择 Containers 中的 <view> 然后会弹出一个面板,选择你写的 CustomeView 这个类即可。

给约束布局添加自定义View

你可能会奇怪为什么我添加的 CustomeView 可以预览,那是因为我构建运行了一遍,你运行完后这里就会记录你上次预览的结果。关于更多的 Canvas 绘制内容后面我会专门出一篇文章说明,这里就以这个 drawOval() 函数为例,本文出自水寒的博客,转载请说明出处:https://dp2px.com

自定义 View 的大小

上面的 CustomeView 实际上我们设置的是 WrapContent 自适应,但是为什么是一个全屏大小,这是因为我们没有去指定它的尺寸和测量方式。如果您需要更好地控制视图的布局参数,可以实现 onMeasure()。这个方法可以告诉你的视图 View 希望显示多大,显示一个固定值还是一个建议值。

好,接下来我们先简单的试一下,给设定一个固定宽高:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(500, 500);
}

结果你会发现它真的不是填充整个屏幕了,而是有了一个固定尺寸,如下图:

设置 onMeasure 后再 ConstraintLayout 中的预览

接下来我们来详细看一下这个方法的两个参数怎么用,我们发现这个 onMeasure 有两个 int 参数,到底是怎么用呢?我们先来看看 View 中的几个方法:

getSuggestedMinimumWidth

返回建议的视图应该使用的最小宽度。这将返回视图的最小宽度和背景的最小宽度的最大值 (Drawable.getMinimumWidth())。没有设置背景,那么返回 mMinWidth ,也就是 android:minWidth 这个属性所指定的值,这个值可以是 0 ;如果 View 设置了背景,则返回 mMinWidth 与背景的最小宽度这两者的最大值。

getSuggestedMinimumHeight

返回建议的视图应该使用的最小高度。这将返回视图的最小高度的最大值和背景的最小高度 (Drawable.getMinimumHeight())。

View.MeasureSpec

MeasureSpec 封装了从父级传递到子级的布局需求。每一个 MeasureSpec 都代表对宽度或高度的要求。MeasureSpec 由尺寸 size 和模态 mode 两部分组成。有三种可能的模式:

UNSPECIFIED:父类没有对子类施加任何约束。它可以是任意大小。

EXACTLY:父进程已经为子进程确定了确切的大小。不管孩子想要多大,他都会得到这些界限。

AT_MOST:在指定的大小范围内,子元素可以任意增大。

resolveSizeAndState

一个计算期望大小和状态的工具方法,通过被强加的 MeasureSpec 来计算。除非有不同的大小限制,否则还是会使用期望的大小。返回的 int 值是一个合成值,通过 MEASURED_SIZE_MASK 来解析出 size,并且使用可选的位 MEASURE_STATE_TOO_SMALL 来标识返回的结果大小比 View 期望的大小还要小。

这个方法在计算尺寸过程中尤为重要,所以接下来我们详细看一下:

public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
    if (Build.VERSION.SDK_INT >= 11) {
        return View.resolveSizeAndState(size, measureSpec, childMeasuredState);
    }
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize =  MeasureSpec.getSize(measureSpec);
    switch (specMode) {
    case MeasureSpec.UNSPECIFIED:
        result = size;
        break;
    case MeasureSpec.AT_MOST:
        if (specSize < size) {
            result = specSize | MEASURED_STATE_TOO_SMALL;
        } else {
            result = size;
        }
        break;
    case MeasureSpec.EXACTLY:
        result = specSize;
        break;
    }
    return result | (childMeasuredState&MEASURED_STATE_MASK);
}

第一个参数 size 表示 View 的期望值大小。

第二个参数 measureSpec 表示父 View 的 measureSpec 限制。

第三个参数 childMeasuredState 是 View.getMeasuredState() 返回的值。布局将使用 View.combineMeasuredStates() 来聚合其子女测量的状态。在大多数情况下,你可以简单地传递0。孩子状态目前仅用于判断视图是否以比想要的尺寸更小的尺寸进行测量。如果需要,此信息又用于调整对话框的大小。

计算步骤:

  1. 判断当前 ROM 版本是否大于 11,如果大于 11 的话,则返回 View.resolveSizeAndState,而 View.resolveSizeAndState 中的方法体和下述方法一致。
  2. 如果 ROM 版本小于 11 的话,首先根据传入的 MeasureSpec 获取 Size 以及 Mode.
  3. 如果父 View 的 MeasureSpec 的 Mode 为 MeasureSpec.UNSPECIFIED 的话,那么 size 等于传入的 size.
  4. 如果父 View 的 MeasureSpec 的 Mode 为 MeasureSpec.AT_MOST 的话,那么就会判断父 View 的 MeasureSpec 中的 size,是否比传入的 size 小,如果小的话,那么 size 等于父容器约束的 specSize,并且加入 MEASURE_STATE_TOO_SMALL 标志位 (result=specSize|MEASURE_STATE_TOO_SMALL),否则 size 等于传入的 size.
  5. 如果父 View 的 MeasureSpec 的 Mode 为 MeasureSpec.EXACTLY 的话,则 size 为 specSize.
  6. 返回的 int 值为 size|(childMeasuredState&MEASURED_STATE_MASK)

结合上面的理解,我们现在修改一下上面的 onMeasure 方法,让我们的自定义 View 自适应:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int minwidth;
    int minheight;
    if(getSuggestedMinimumWidth() > WIDTH_HEIGHT){ //判断设置的最小宽度是否大于实际宽度
        minwidth = getSuggestedMinimumWidth() + getPaddingLeft() + getPaddingRight();
    }else {
        minwidth = WIDTH_HEIGHT + getPaddingLeft() + getPaddingRight();
    }
    if(getSuggestedMinimumHeight() > WIDTH_HEIGHT){ //判断设置的最小高度是否大于实际高度
        minheight = getSuggestedMinimumHeight() + getPaddingBottom() + getPaddingTop();
    }else{
        minheight = WIDTH_HEIGHT + getPaddingBottom() + getPaddingTop();
    }
    int w = resolveSizeAndState(minwidth, widthMeasureSpec, 1);
    int h = resolveSizeAndState(minheight, heightMeasureSpec, 0);
    setMeasuredDimension(w, h);
}

这里需要注意一下,我们前面在 onDraw() 中的绘制起点是 (100, 100) 所以会导致我们实际占用的视图大小是 300 宽和 300 高,而不是 200 x 200. 所以我们先让起点处于 (0, 0)

private static final int WIDTH_HEIGHT = 200;
private static final int START_X_Y = 0;

mRect.set(START_X_Y, START_X_Y, START_X_Y + WIDTH_HEIGHT, START_X_Y + WIDTH_HEIGHT);

调整onMeasure让其自适应大小

你可以尝试去布局文件中修改 padding 和 minWidth 属性,完全不影响我们的 CustomeView 的显示。

自定义 Attributes 属性

我们上面定义的椭圆宽高是写死到 CustomeView 中的,这样会导致我们写的组件灵活性很差,只能用于特定的地方,像这种组件的意义不大,所以我们现在将这个尺寸数据让可以在布局的 xml 中配置。

在我们的资源文件夹中创建 <declare-styleable> 资源来定义 attributes:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomeView">
        <attr name="width" format="dimension"></attr>
        <attr name="heigh" format="dimension"></attr>
        <attr name="color" format="color"></attr>
    </declare-styleable>
</resources>

接下来我们在 namespace 中引入你的包名命名空间 app ,然后添加我们定义的属性:

<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    <!--省略-->
    >

    <view
        class="com.test.innersharecode12.custome.CustomeView"

         <!--省略-->

        app:width="200dp"
        app:heigh="200dp"
        app:color="@color/colorPrimary"/>
</android.support.constraint.ConstraintLayout>

然后我们打开 CustomeView 来读取这些属性并配置到具体的 RectF 上面:

private void initTypeArray(Context context, AttributeSet attrs){
    TypedArray typedArray = context.getTheme().obtainStyledAttributes(
            attrs, R.styleable.CustomeView, 0, 0);
    try {
        mWidth = typedArray.getDimensionPixelSize(R.styleable.CustomeView_width, DEFAULT_WIDTH_HEIGHT);
        mHeight = typedArray.getDimensionPixelOffset(R.styleable.CustomeView_heigh, DEFAULT_WIDTH_HEIGHT);
        mColor = typedArray.getColor(R.styleable.CustomeView_color, DEFAULT_COLOR);
    } finally {
        typedArray.recycle();
    }
}

执行,预览一下效果,我们现在尺寸和颜色都发生了变化:

使用自定义 Attributes 属性后的预览

自定义 ViewGroup

自定义 ViewGroup 和自定义 View 类似,我们还要额外实现一个 onLayout() 方法来对里面的子 View 进行布局。我们先考虑一下,作为一个容器是不是给子容器一个建议宽高(上面提到过)和测量模式,还有就是决定子 View 的位置和相对位置。

ViewGroup 需要给 View 传入 view 的测量值和模式(onMeasure中完成),而且对于此 ViewGroup 的父布局,自己也需要在 onMeasure 中完成对自己宽和高的确定。此外,需要在 onLayout 中完成对其 childView 的位置的指定。

说明:下面的案例来自鸿翔的博客。

决定该 ViewGroup 的 LayoutParams

对于我们这个例子,我们只需要 ViewGroup 能够支持 margin 即可,那么我们直接使用系统的 MarginLayoutParams.

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

重写父类的该方法,返回 MarginLayoutParams 的实例,这样就为我们的 ViewGroup 指定了其 LayoutParams 为 MarginLayoutParams。

重新 onMeasure 方法

在 onMeasure 中计算 childView 的测量值以及模式,以及设置自己的宽和高:

/**
    * 计算所有ChildView的宽度和高度 然后根据ChildView的计算结果,设置自己的宽和高
    */
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    /**
        * 获得此ViewGroup上级容器为其推荐的宽和高,以及计算模式
        */
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
    int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);


    // 计算出所有的childView的宽和高
    measureChildren(widthMeasureSpec, heightMeasureSpec);
    /**
        * 记录如果是wrap_content是设置的宽和高
        */
    int width = 0;
    int height = 0;

    int cCount = getChildCount();

    int cWidth = 0;
    int cHeight = 0;
    MarginLayoutParams cParams = null;

    // 用于计算左边两个childView的高度
    int lHeight = 0;
    // 用于计算右边两个childView的高度,最终高度取二者之间大值
    int rHeight = 0;

    // 用于计算上边两个childView的宽度
    int tWidth = 0;
    // 用于计算下面两个childiew的宽度,最终宽度取二者之间大值
    int bWidth = 0;

    /**
        * 根据childView计算的出的宽和高,以及设置的margin计算容器的宽和高,主要用于容器是warp_content时
        */
    for (int i = 0; i < cCount; i++)
    {
        View childView = getChildAt(i);
        cWidth = childView.getMeasuredWidth();
        cHeight = childView.getMeasuredHeight();
        cParams = (MarginLayoutParams) childView.getLayoutParams();

        // 上面两个childView
        if (i == 0 || i == 1)
        {
            tWidth += cWidth + cParams.leftMargin + cParams.rightMargin;
        }

        if (i == 2 || i == 3)
        {
            bWidth += cWidth + cParams.leftMargin + cParams.rightMargin;
        }

        if (i == 0 || i == 2)
        {
            lHeight += cHeight + cParams.topMargin + cParams.bottomMargin;
        }

        if (i == 1 || i == 3)
        {
            rHeight += cHeight + cParams.topMargin + cParams.bottomMargin;
        }

    }
    
    width = Math.max(tWidth, bWidth);
    height = Math.max(lHeight, rHeight);

    /**
        * 如果是wrap_content设置为我们计算的值
        * 否则:直接设置为父容器计算的值
        */
    setMeasuredDimension((widthMode == MeasureSpec.EXACTLY) ? sizeWidth
            : width, (heightMode == MeasureSpec.EXACTLY) ? sizeHeight
            : height);
}

10-14行,获取该 ViewGroup 父容器为其设置的计算模式和尺寸,大多情况下,只要不是 wrap_content,父容器都能正确的计算其尺寸。所以我们自己需要计算如果设置为 wrap_content 时的宽和高,如何计算呢?那就是通过其 childView 的宽和高来进行计算。

17行,通过 ViewGroup 的 measureChildren 方法为其所有的孩子设置宽和高,此行执行完成后,childView 的宽和高都已经正确的计算过了

43-71行,根据 childView 的宽和高,以及 margin,计算 ViewGroup 在 wrap_content 时的宽和高。

80-82行,如果宽高属性值为 wrap_content,则设置为 43-71 行中计算的值,否则为其父容器传入的宽和高。

onLayout 对其所有 childView 进行定位

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    int cWidth = 0;
    int cHeight = 0;
    MarginLayoutParams cParams = null;
    /**
        * 遍历所有childView根据其宽和高,以及margin进行布局
        */
    for (int i = 0; i < childCount; i++)
    {
        View childView = getChildAt(i);
        cWidth = childView.getMeasuredWidth();
        cHeight = childView.getMeasuredHeight();
        cParams = (MarginLayoutParams) childView.getLayoutParams();
        int cl = 0, ct = 0, cr = 0, cb = 0;
        switch (i)
        {
            case 0:
                cl = cParams.leftMargin;
                ct = cParams.topMargin;
                break;
            case 1:
                cl = getWidth() - cWidth - cParams.leftMargin
                        - cParams.rightMargin;
                ct = cParams.topMargin;

                break;
            case 2:
                cl = cParams.leftMargin;
                ct = getHeight() - cHeight - cParams.bottomMargin;
                break;
            case 3:
                cl = getWidth() - cWidth - cParams.leftMargin
                        - cParams.rightMargin;
                ct = getHeight() - cHeight - cParams.bottomMargin;
                break;

        }
        cr = cl + cWidth;
        cb = cHeight + ct;
        childView.layout(cl, ct, cr, cb);
    }
}

遍历所有的 childView,根据 childView 的宽和高以及 margin,然后分别将 0,1,2,3 位置的 childView 依次设置到左上、右上、左下、右下的位置。

给自定义的 ViewGroup 添加子 View

<?xml version="1.0" encoding="utf-8"?>
<com.test.innersharecode12.custome.CustomeViewGroup
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="10dip">

    <com.test.innersharecode12.custome.CustomeView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:width="200dip"
        app:heigh="200dip"
        app:color="@color/colorPrimary"/>

    <com.test.innersharecode12.custome.CustomeView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:color="@color/colorAccent"/>

    <com.test.innersharecode12.custome.CustomeView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <com.test.innersharecode12.custome.CustomeView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:width="200dip"
        app:heigh="200dip"
        app:color="@color/colorPrimaryDark"/>
</com.test.innersharecode12.custome.CustomeViewGroup>

最后运行,结果如下:

自定义ViewGroup布局示例

本文所有源码可以在 GitHub 下载:https://github.com/lxqxsyu/InnerShareCode12