自定义控件可以让代码的重用达到一个新的高度,就跟用ButtonImageView一样方便。

这一篇是不带手势和触摸的自定义控件,后面会补一篇,原因是这两个都是内容比较多的,所以分开说。

概述

Android中所有显示在界面上的东西,称之为控件,每一个控件都有一个共同的父类:android.view.View,熟悉的ButtonImageView都是android.view.View的子类,所以自定义控件也是如此,本质上都是继承android.view.View

自定义控件的方式很多:

  • 继承android.view.View
    一般情况下的控件只需要继承View
  • 继承android.view.ViewGroup
    ViewGroup也是继承的View,如果控件本身是一个布局或者有自己的子控件,那么继承ViewGroup就好了
  • 继承已有的其他控件
    如果自定义控件是对现有控件的扩展,直接继承这个控件即可。

自定义控件的步骤

这篇中最重要的只有三个点,后面的东西都是围绕这三个点进行:

  • 计算(onMeasure)
  • 布局(onLayout)
  • 绘制(onDraw)

计算:是计算控件的大小,什么情况下应该是多大,如果这是一个ViewGroup还需要计算给子控件的大小,总之就是计算宽高用的。
布局:自定义控件的控件位置的摆放,在布局的过程中,可以获取到控件的大小以便计算。
绘制:通过Canvas可以绘制各种形状和内容,也可以实现动画。

这三者的调用顺序是:onMeasure -> onLayout -> onDraw。在这过程中onMeasure可能会被多次调用。

先了解一下这三者的功能和基本的使用方式,后面用例子来完整的介绍整个过程。

onMeasure

在介绍onMeasure之前,先看一个重要的工具类:MeasureSpec。一个MeasureSpec封装了父布局传递给子布局的布局要求,每个MeasureSpec代表了一组宽度和高度的要求,一个MeasureSpec由大小和模式组成它有三种模式:

  • UNSPECIFIED(未指定)
    父元素不对子元素施加任何束缚,子元素可以得到任意想要的大小
  • EXACTLY(完全)
    父元素决定子元素的确切大小,子元素将被限定在给定的边界里面而忽略它本身大小
  • AT_MOST(至多)
    子元素至多达到指定大小的值。

它常用的三个函数:
static int getMode(int measureSpec)
根据提供的测量值(格式)提取模式(上述三个模式之一)
static int getSize(int measureSpec)
根据提供的测量值(格式)提取大小值(这个大小也就是通常所说的大小)
static int makeMeasureSpec(int size,int mode)
根据提供的大小值和模式创建一个测量值(格式)

在计算控件本身的空间的时候,会根据三种不同的模式,按照自己的想法计算控件大小。现在实现一个简单的自定义控件来详细说明这个onMeasure是何物,有三个要求:
– 自定义控件的默认大小是100 * 100
当布局不能明确确定控件大小的时候,比如wrap_content,控件大小是100 * 100
– 然后最大可以跟随父控件的大小
布局中,父控件给子控件的空间是多大,子控件就使用多大空间
– 最后是可以明确的指定大小
布局文件中对应的layout属性是直接写死的,比如50dp,但是,如果这个时候父控件给定的空间比50dp要小,那么以父控件的大小为准。

现在先来第一个条件,默认大小是100 * 100

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    final int paddingLeft = getPaddingLeft();
    final int paddingRight = getPaddingRight();
    final int paddingTop = getPaddingTop();
    final int paddingBottom = getPaddingBottom();

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

    int desired = (int) (100 * getResources().getDisplayMetrics().density + 0.5f);
    widthSize = Math.min(desired, widthSize);
    heightSize = Math.min(desired, heightSize);
    setMeasuredDimension(widthSize, heightSize);
}

上面4行关于padding的,是用来计算大小的时候考虑到padding的情况用的。用之前说的方法获取到宽和高分别的模式和提供出来的大小,然后设置一个默认的100dp的大小desired,然后宽和高都跟默认值比,取最小的一个值作为控件的大小,现在是什么效果呢?

  • 如果控件布局是wrap_content,这时候传进来可以给子控件用的大小是屏幕分辨率尺寸,那么控件的大小是100 * 100
  • 如果控件布局是大于100 * 100,同第一条一样还是取最小值是100 * 100
  • 如果控件布局小于100 * 100,那么控件大小就是自己设定的值。

注意:setMeasuredDimension(widthSize, heightSize);方法是设置确定子控件占的空间位置的,必须调用,或者使用super.onMeasure(int widthMeasureSpec, int heightMeasureSpec),这个方法最后也会调用这个函数来确定控件大小。

那现在怎么让子控件的大小跟随父控件大小变化呢?这个很简单,因为onMeasure(int widthMeasureSpec, int heightMeasureSpec)中传过来的宽高就是父控件能够给子控件宽高的界限,所以只需要设置成传过来的宽高:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    final int paddingLeft = getPaddingLeft();
    final int paddingRight = getPaddingRight();
    final int paddingTop = getPaddingTop();
    final int paddingBottom = getPaddingBottom();

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

    setMeasuredDimension(widthSize, heightSize);
}

最后一条,如何明确的指定大小?代码跟上面一模一样,为什么呢?因为如果子控件布局有明确的值的时候比如:android:layout_width="100dp"onMeasure中传过来的值就是这个明确的值,只有父控件不能确定子控件大小的时候这个值是屏幕的分辨率。
总结一下onMeasure的三个特点:

  • 父控件会限制子控件获得空间的界限。
  • 子控件布局中有确定值的时候,父控件能够明确知道子控件需要多大的空间,所以传过来的值就是子控件设置的值。
  • 子控件通过model,也就是模型来区分自己应该在什么情况下占用多少空间

之前那三条需求是分开的,也就是说他们之间是冲突的满足其中一个就不能满足另外的,那么下面实现一个完整的,类似系统控件的onMeasure来适应各种情况,在实际项目中,可以直接按照这样来做:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
    final int paddingLeft = getPaddingLeft();
    final int paddingRight = getPaddingRight();
    final int paddingTop = getPaddingTop();
    final int paddingBottom = getPaddingBottom();

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

    // 先计算控件的宽暂用的空间
    if(widthMode == MeasureSpec.EXACTLY)
    {// 确定的大小就是通过MeasureSpec.getSize()获取出来的值
    }
    else
    {// 这里就是大小不确定,主要wrap_content或者父控件大小不确定的时候的match_parent
        int desired = (int) (100 * getResources().getDisplayMetrics().density + 0.5f);
        if (widthMode == MeasureSpec.AT_MOST)
        {
            widthSize = Math.min(desired, widthSize);
        }
    }
    // 计算控件的高暂用的空间
    if(heightMode == MeasureSpec.EXACTLY)
    {// 确定的大小就是通过MeasureSpec.getSize()获取出来的值
    }
    else
    {
        int desired = (int) (100 * getResources().getDisplayMetrics().density + 0.5f);
        if (heightMode == MeasureSpec.AT_MOST)
        {
            heightSize = Math.min(desired, heightSize);
        }
    }
    setMeasuredDimension(widthSize, heightSize);
}

主要解释一下else中的东西,如果宽或者高有明确的大小,直接使用设置的明确的值,如果不是确定的值就有很多种可能,这个时候如果是MeasureSpec.AT_MOST,也就是父控件给子控件的空间是一个最大的界限,这时候直接取最小就可以了。
如何才能快速掌握onMeasure呢?各种模式的关系还有调用机制,需要自己亲自去体验,写一个例子去调值来理解,多写多看其他控件的onMeasure实现就能理解了,没有理解清楚的时候,如果要实现一个漂亮的onMeasure参照上面的代码,按照需求定制就行了,一般情况下就够用了,慢慢的就有知道怎么回事了。

onLayout

先看看函数声明:onLayout(boolean changed, int left, int top, int right, int bottom),有5个参数,第一个的意思是控件的尺寸是否改变,如果前一次和这一次尺寸有变化,则是true,为什么会有几次的说法呢?原因是存在界面刷新(分为系统刷新和手动刷新)或者手动修改控件尺寸的情况,而且android在绘制控件的时候,特别是层级复杂的时候这个方法会多次调用,这个值用于提高性能。后面4个值是相对于父控件的位置,比如left则是相对于父控件的left的值,如果父控件相对于屏幕左边是100,那么子控件的位置相对于屏幕是100,但是这个left值是可能是0,如果也是100的话,它相对于屏幕左边就是200的距离了。
一般情况下,如果自定义控件是没有子控件的,这个直接使用默认实现就好了,如果这个自定义控件本身可以包含子控件,那么可以在这里对子控件的位置进行摆放,很简单就是你想把子控件放哪里你就用子控件的layout()方法摆放就完了:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b)
{
    super.onLayout(changed, l, t, r, b);
    ArrayList<PointF> points = mView.getKeyPoints(getPointCount());
    for (int i = 1; i < getChildCount(); ++i)
    {
        View childAt = getChildAt(i);
        if (childAt instanceof RewardView || childAt instanceof ImageView)
        {
            continue;
        }
        PointF point = points.get(i - 1);
        final int start = (int) (mView.getPaddingLeft() + point.x);
        childAt.layout(start - childAt.getWidth() / 2, (int) point.y - childAt.getHeight() / 2, start + childAt.getWidth() / 2, (int) point.y + childAt.getHeight() / 2);
    }
    if (points.size() > mArrowIndex + 1 && mArrowIndex != -1)
    {
        addArrow(points.get(mArrowIndex), points.get(mArrowIndex + 1));
    }
}

后面会有一个完整的例子来说明如何onLayout

onDraw

绘制,跟onLayout相反,这个主要是用于没有子控件的自定义控件用的,一般继承View。通过Canvas可以绘制各种图形,这里可以给控件添加想要的图形效果。

到此,三个步骤就介绍完了,接下来实现一个

构造函数

属性获取

实例

一些可能会遇到的坑

  • 自定义属性和原生属性冲突

总结