Android 自定义 View 控件

01.自定义控件步骤

  • 根据Android Developers官网的介绍,自定义控件你需要以下的步骤。(根据你的需要,某些步骤可以省略)
    • 1、创建View
    • 2、处理View的布局
    • 3、绘制View
    • 4、与用户进行交互
    • 5、优化已定义的View
  • 上面列出的五项就是android官方给出的自定义控件的步骤。
    • 每个步骤里面又包括了很多细小的知识点。可以记住这五个点,并且了解每个点里包含的小知识点。再加上一些自定义控件的练习。不断的将这些知识熟练于心,相信我们每个人都能够定义出优秀的自定义控件。接下来我们开始对上面列出的5个要点进行细化解说。

02.创建View

  • 继承View
    • Android为我们提供的很多View都是继承与View的。
    • 所以我们自定义的View当然也是继承于View,当然如果你要自定义的View拥有某些android已经提供的控件的功能,你可以直接继承于已经提供的控件。
    • 在使用Android提供的控件的时候,我们在.xml文件中编辑了一个控件,在运行的时候就能够看到和获得这个控件。我们自定义的控件当然也要支持配置和一些自定义属性,所以下面的构造方法就必须有了。这个构造方法允许我们在.xml文件中创建和编辑我们自定义控件的实例。

2.1 重写构造方法

  • 上面说了那么多其实就是下面一段代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class NumberProgressbar extends View {

    public NumberProgressbar(Context context) {
    this(context, null);
    }

    public NumberProgressbar(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
    }

    public NumberProgressbar(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initializeAttrs(context,attrs,defStyleAttr);
    initializePainters();
    }

    }

2.2 定义自定义属性

  • 大部分情况我们的自定义View需要有更多的灵活性

    • 比如我们在xml中指定了颜色大小等属性,在程序运行时候控件就能展示出相应的颜色和大小。所以我们需要自定义属性
  • 自定义属性通常写在在res/values/attrs.xml文件中 下面是自定义属性的标准写法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <declare-styleable name="NumberProgressbar">
    <attr name="progress_current" format="integer"/>
    <attr name="progress_max" format="integer"/>

    <attr name="progress_unreached_color" format="color"/>
    <attr name="progress_reached_color" format="color"/>
    <attr name="progress_reached_bar_height" format="dimension"/>
    <attr name="progress_unreached_bar_height" format="dimension"/>

    <attr name="progress_text_size" format="dimension"/>
    <attr name="progress_text_color" format="color"/>
    <attr name="progress_text_offset" format="dimension"/>
    <attr name="progress_text_visibility" format="enum">
    <enum name="visible" value="0"/>
    <enum name="invisible" value="1"/>
    </attr>
    </declare-styleable>
    • 这段代码声明了自定义属性,它们都是属于styleable,为了方便,一般styleable的name和我们自定义控件的类名一样。自定义控件定义好了之后就是使用了。
  • 使用代码示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <com.ns.yc.ycprogresslib.NumberProgressbar
    android:id="@+id/bar"
    android:layout_marginTop="10dp"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:progress_max="100"
    app:progress_reached_bar_height="3dp"
    app:progress_unreached_bar_height="3dp"
    app:progress_reached_color="@color/colorPrimary"
    app:progress_unreached_color="@color/gray3"
    app:progress_text_size="14sp"
    app:progress_text_color="@color/colorAccent"
    app:progress_text_visibility="visible"/>

2.3 获取自定义属性

  • 在xml中设置了控件自定义属性,我们就需要拿到属性做一些事情。否则定义自定义属性就没有意义了。

    • 获取自定义属性代码如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /**
    * 初始化自定义属性
    * @param context 上下文
    * @param attrs attrs
    * @param defStyleAttr defStyleAttr
    */
    private void initializeAttrs(Context context, AttributeSet attrs, int defStyleAttr) {
    final TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.NumberProgressbar, defStyleAttr, 0);
    mReachedBarColor = attributes.getColor(R.styleable.NumberProgressbar_progress_reached_color, Color.GRAY);
    mUnreachedBarColor = attributes.getColor(R.styleable.NumberProgressbar_progress_unreached_color, Color.DKGRAY);
    mTextColor = attributes.getColor(R.styleable.NumberProgressbar_progress_text_color, Color.BLACK);
    mTextSize = attributes.getDimension(R.styleable.NumberProgressbar_progress_text_size, ProgressBarUtils.sp2px(context,12));
    mReachedBarHeight = attributes.getDimension(R.styleable.NumberProgressbar_progress_reached_bar_height, ProgressBarUtils.dp2px(context,1.5f));
    mUnreachedBarHeight = attributes.getDimension(R.styleable.NumberProgressbar_progress_unreached_bar_height, ProgressBarUtils.dp2px(context,1.0f));
    mOffset = attributes.getDimension(R.styleable.NumberProgressbar_progress_text_offset, ProgressBarUtils.dp2px(context,2.0f));
    int textVisible = attributes.getInt(R.styleable.NumberProgressbar_progress_text_visibility, 0);
    if (textVisible != 0) {
    mTextIsVisible = false;
    }
    setProgress(attributes.getInt(R.styleable.NumberProgressbar_progress_current, 0));
    setMax(attributes.getInt(R.styleable.NumberProgressbar_progress_max, 100));
    attributes.recycle();
    }
    • 当我们在 xml中创建了一个view时,所有在xml中声明的属性都会被传入到view的构造方法中的AttributeSet类型的参数当中。
    • 通过调用Context的obtainStyledAttributes()方法返回一个TypedArray对象。然后直接用TypedArray对象获取自定义属性的值。
    • 由于TypedArray对象是共享的资源,所以在获取完值之后必须要调用recycle()方法来回收。

2.4 添加设置属性事件

  • 在xml中指定的自定义属性只有在view被初始化的时候能够获取到,有时候我们可能在运行时做一些操作,这种情况就需要我们为自定义属性设置getter和setter方法,以下代码展示了自定义控件暴露的set 和get方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /**
    * 设置进度条文本的大小
    * @param textSize textSize
    */
    public void setProgressTextSize(float textSize) {
    this.mTextSize = textSize;
    mTextPaint.setTextSize(mTextSize);
    invalidate();
    }

    /**
    * 设置进度条文本的颜色
    * @param textColor textColor
    */
    public void setProgressTextColor(@ColorInt int textColor) {
    this.mTextColor = textColor;
    mTextPaint.setColor(mTextColor);
    invalidate();
    }
    • 重点看setProgressTextSize方法,在为mTextSize赋值之后,调用了invalidate()或者requestLayout()方法,我们自定义控件的属性发生改变之后,控件的样子也可能发生改变,在这种情况下就需要调用invalidate()方法让系统去调用view的onDraw()重新绘制。
    • 同样的,控件属性的改变可能导致控件所占的大小和形状发生改变,所以我们需要调用requestLayout()来请求测量获取一个新的布局位置。

03.测量View(Measure)

  • 测量

    • 一个View是在展示时总是有它的宽和高,测量View就是为了能够让自定义的控件能够根据各种不同的情况以合适的宽高去展示。提到测量就必须要提到onMeasure方法了。onMeasure方法是一个view确定它的宽高的地方。
    • 代码如下
    1
    2
    3
    4
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    }

3.1 widthMeasureSpec参数

  • onMeasure方法里有两个重要的参数,widthMeasureSpec,heightMeasureSpec。

    • 在这里你只需要记住它们包含了两个信息:mode和size
  • 可以通过以下代码拿到mode和size

    1
    2
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);
  • 那么获取到的mode和size又代表了什么呢?

    • mode代表了我们当前控件的父控件告诉我们控件,你应该按怎样的方式来布局。
      • mode有三个可选值:EXACTLY, AT_MOST, UNSPECIFIED。它们的含义是:
      • EXACTLY:父控件告诉我们子控件了一个确定的大小,你就按这个大小来布局。比如我们指定了确定的dp值和macth_parent的情况。
      • AT_MOST:当前控件不能超过一个固定的最大值,一般是wrap_content的情况。
      • UNSPECIFIED:当前控件没有限制,要多大就有多大,这种情况很少出现。
    • size其实就是父布局传递过来的一个大小,父布局希望当前布局的大小。

3.2 重写onMeasure伪代码

  • 下面是一个重写onMeasure的固定伪代码写法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    if mode is EXACTLY{
    父布局已经告诉了我们当前布局应该是多大的宽高, 所以我们直接返回从measureSpec中获取到的size
    }else{
    计算出希望的desiredSize
    if mode is AT_MOST
    返回desireSize和specSize当中的最小值
    else:
    返回计算出的desireSize
    }
    • 上面的代码虽然基本都是固定的,但是需要写的步骤还是有点多,如果你不想自己写,你也可以用android为我们提供的工具方法:resolveSizeAndState,该方法需要传入两个参数:我们测量的大小和父布局希望的大小,它会返回根据各种情况返回正确的大小。这样我们就可以不需要实现上面的模版,只需要计算出想要的大小然后调用resolveSizeAndState。之后在做自定义View的时候我会展示用这个方法来确定view的大小。
  • 实际案例中代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int measureWidth = measure(widthMeasureSpec, true);
    int measureHeight = measure(heightMeasureSpec, false);
    setMeasuredDimension(measureWidth, measureHeight);
    }

    private int measure(int measureSpec, boolean isWidth) {
    int result;
    int mode = MeasureSpec.getMode(measureSpec);
    int size = MeasureSpec.getSize(measureSpec);
    int padding = isWidth ? getPaddingLeft() + getPaddingRight() : getPaddingTop() + getPaddingBottom();
    if (mode == MeasureSpec.EXACTLY) {
    result = size;
    } else {
    result = isWidth ? getSuggestedMinimumWidth() : getSuggestedMinimumHeight();
    result += padding;
    if (mode == MeasureSpec.AT_MOST) {
    if (isWidth) {
    result = Math.max(result, size);
    } else {
    result = Math.min(result, size);
    }
    }
    }
    return result;
    }
    • 计算出height和width之后在onMeasure中别忘记调用setMeasuredDimension()方法。否则会出现运行时异常。

3.3 onSizeChange()作用

  • 计算一些自定义控件需要的值 onSizeChange()
    • onSizeChange() 方法在view第一次被指定了大小值、或者view的大小发生改变时会被调用。所以一般用来计算一些位置和与view的size有关的值。

04.绘制View(Draw)

  • 一旦自定义控件被创建并且测量代码写好之后,接下来你就可以实现onDraw()来绘制View。
    • onDraw方法包含了一个Canvas叫做画布的参数,onDraw()简单来说就两点:
      • Canvas决定要去画什么
      • Paint决定怎么画
    • 比如,Canvas提供了画线方法,Paint就来决定线的颜色。Canvas提供了画矩形,Paint又可以决定让矩形是空心还是实心。
  • 在onDraw方法中开始绘制之前
    • 你应该让画笔Paint对象的信息初始化完毕。这是因为View的重新绘制是比较频繁的,这就可能多次调用onDraw,所以初始化的代码不应该放在onDraw方法里。

05.与用户进行交互

  • 也许某些情况你的自定义控件不仅仅只是展示一个漂亮的内容,还需要支持用户点击,拖动。这时候自定义控件就需要做用户交互这一步骤了。

  • 在Android系统中最常见的事件就是触摸事件了,它会调用view的onTouchEvent(android.view.MotionEvent).重写这个方法去处理我们的事件逻辑。

    1
    2
    3
    4
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    return super.onTouchEvent(event);
    }
  • 现在的触控有了更多的手势,比如轻点,快速滑动等等,所以在支持特殊用户交互的时候你需要用到android提供的GestureDetector.你只需要实现GestureDetector中相对应的接口,并且处理相应的回调方法。

  • 除了手势之外,如果有移动之类的情况我们还需要让滑动的动画显示得比较平滑。动画应该是平滑的开始和结束,而不是突然消失突然开始。在这种情况下,我们需要用到属性动画 property animation framework

06.优化自定义View

  • 在上面的步骤结束之后,其实一个完善的自定义控件已经出来了。接下来你要做的只是确保自定义控件运行得流畅,官方的说法是:为了避免你的控件看得来迟缓,确保动画始终保持每秒60帧。
  • 下面是官网给出的优化建议:
    • 1、避免不必要的代码
    • 2、在onDraw()方法中不应该有会导致垃圾回收的代码。
    • 3、尽可能少让onDraw()方法调用,大多数onDraw()方法调用都是手动调用了invalidate()的结果,所以如果不是必须,不要调用invalidate()方法。
  • 总结
    • image

05.手动测量控件宽高

  • 比其他的方法多调用了一次onMeasure()方法,该方法虽然看上去简单,但是如果要目标控件计算耗时比较大的话(如listView等),不建议使用
  • 如果是简单的button,textView等控件,用该方法也可以