整个自定义 View

2,004 阅读7分钟
原文链接: chen-wei.me

原文链接

介绍

我们每天都在使用各种应用,尽管这些应用的用途各不相同,但是大多数应用在设计上是相似的。这也是为什么很多用户希望应用有定制化的布局、新奇的界面,以区别于其他雷同的应用。

如果某个特定的功能需要一个特定的视图,其不能通过Android内置的View实现——这时就需要通过自定义View来实现了。在绝大多数情况下,实现一个自定义View需要花费很长时间。但是这并不表示我们不应该实现它,相反,这个过程即令人兴奋又有趣。

我最近就碰到这种情况:我的任务是实现ViewPager的分页指示器。不同于iOS,Android并没有提供类似的试图组件,所以我不得不通过自定义View的方式来实现。

我花费了相当多的时间来实现这个自定义View。幸运的是,如今这个项目完全可以复用到其他项目中,所以为了节省其他程序员的时间,我决定将其作为库发布出去。如果你有类似的需求并且没有时间自己实现,可以在github仓库上获取。

搞起!

当然,相对于系统内置的View组件,自定义View会更耗费时间。所以除非没有更简单的方式来实现特定功能,不要轻易的使用自定义View。以下情况下可以通过自定义View解决:

  1. 性能。如果布局文件中有大量视图,可以通过单个自定义View使之更加轻量。
  2. 复杂的View层级,难以操作和维护。
  3. 需要手动绘制的复杂的自定义View。

如果你还未尝试过自定义View,这篇博文是个极好的机会,让你熟悉如何实现自定义View。本篇博文包含了以下内容:View的总体结构,如何实现特殊的视图,如何避免常见的错误以及如何实现视图动画!

第一个重要的内容就是View生命周期。由于某些原因,Google并没有提供一个官方的View生命周期图示,这也使得很多程序员并不了解View生命周期,从而导致了很多Bug和问题。所以我们需要牢牢掌握View生命周期!

构造器

每个视图的生命周期都是从构造器开始的。在构造器中可以做初始化操作,进行各种计算,设置默认值等。

使用AttributeSet接口可以让自定义View更易使用和配置。实现自定义属性非常容易,而且值得在这方面花费时间,因为这会帮助你(以及你的团队)通过一些静态参数配置自定义View。

第一步,需要新建attrs.xml文件。在attrs.xml中可以为不同的自定义View定义属性。在我们的例子中我定义了PageIndicatorview以及其属性piv_count

第二步,在构造器中获取属性。

public PageIndicatorView(Context context, AttributeSet attrs) {
    super(context, attrs);
    TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.PageIndicatorView);
    int count = typedArray.getInt(R.styleable.PageIndicatorView_piv_count,0);
    typedArray.recycle();
}

注意:

  • 当创建自定义属性时,使用一个简单的前缀避免不同的自定义View之间具有相同的属性名,从而导致名字冲突。多数情况下这个前缀是自定义View的缩写,例如我们的前缀为piv_
  • 如果你的IDE为Android Studio,Lint工具会在获取完属性后建议你调用recycle()方法。这是为了删除不再使用的绑定数据。

onAttachedToWindow

在父视图调用 addView(View) 后,该视图会被绑定到某个Window上。在这个阶段,自定义View会知道其周围有哪些视图。如果该视图与layout.xml中的其他视图有耦合,可以在 onAttachedToWindow 中通过findViewById(在View的属性中配置)获取它们并且将其保存为局部变量(如果需要的话)。

onMesure

自定义View会在这个阶段获取其自身的大小。onMeasure方法至关重要,很多情况下需要知道View的具体大小来填充布局。

覆写该方法时,你需要做的就是设置setMeasuredDimension(int width, int height)

当设置自定义View的大小时你需要处理这种情况:已经通过layout.xml或者Java代码指定了View的宽高。为了计算大小,需要做以下几步:

  1. 计算视图内容所需的大小(宽高)。
  2. MeasureSpec中获取宽高的大小和模式。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
}
  1. 根据MeasureSpec模式判断用户是否设置了View的具体大小。
int width;
if (widthMode == MeasureSpec.EXACTLY) {
    width = widthSize;
} else if (widthMode == MeasureSpec.AT_MOST) {
    width = Math.min(desiredWidth, widthSize);
} else {
    width = desiredWidth;
}

MeasureSpec有三种模式:

  • MeasureSpec.EXACTLY表示用户设置了具体的值,所以无论你的视图有多大,都需要设置具体的宽或高。
  • MeasureSpec.AT_MOST表示该视图的尺寸为其所需的具体大小。(译者注:wrap_content)
  • MeasureSpec.UNSPECIFIED表示会包裹整个视图的大小。使用上面计算的desiredWidth或desiredHeight。(译者注:match_parent)

在调用setMeasuredDimension()方法前,以防万一,判断一下传递的参数是否非负。这会在布局时避免一些问题。

onLayout

该方法为子View分配大小和位置。由于我们的自定View(继承自View)并不包含子View,所以无需重写该方法。

onDraw

这里便是自定View的神奇之处。使用CanvasPaint对象可以绘制任何你想要的视图。

Canvas实例来自onDraw方法的参数,当Paint对象定义了颜色后,Canvas可以绘制不同的形状。简单来说,Paint定义画笔,Canvas绘制具体的图形。大多数情况下就是绘制一条线,一个圆或者一个矩形。

实现自定义View时,铭记onDraw方法被调用了很多次。属性变化、滚动、滑动都会导致视图的重绘。所以Android Studio建议不要在onDraw方法中分配对象,可以使用局部变量替代临时变量。

注意:

  • 执行绘制时,铭记要复用对象而非创建新的对象。不要依赖于IDE高亮潜在的问题,因为如果onDraw调用了某个方法,这个方法中创建了对象,这时IDE并不能够觉察。
  • 在绘制是不要对视图的大小进行硬编码。因为其他开发者可能会使用不同尺寸的视图,所以需要根据视图具体的大小进行绘制。

更新视图

从VIew生命周期图表上可以看到,有两个方法会导致View重绘。invalidate()以及requestLayout()方法可以用户构造可交互的自定义View,在运行时改变视图。但是为什么会有两个方法呢?

  • invalidate()方法通常用来简单地重绘视图。例如更新文本,颜色或者触控交互。只是通过再次调用onDraw()方法更新状态。
  • 对于requestLayout()方法,正如图中所示,会从onMeasure()方法开始重走一遍View的生命周期。这表示你需要重新计算View需要绘制的大小。

动画

自定义View的动画是一帧一帧的过程。对于我们的例子,这意味着你需要使圆点的半径从小变到大,你需要一点一点的增加其半径值,并在每一步调用 invalidata() 重绘视图。

ValueAnimator是实现自定义View动画时最好的帮手。这个类可以帮助你计算从开始到结束的属性值,必要时还可以使用Interpolator改变动画执行的速度。

ValueAnimator animator = ValueAnimator.ofInt(0, 100);
animator.setDuration(1000);
animator.setInterpolator(new DecelerateInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    public void onAnimationUpdate(ValueAnimator animation) {
        int newRadius = (int) animation.getAnimatedValue();
    }
});
animator.start();

注意: 不要忘记在每次动画值改变时调用 invalidate() 方法。

希望这篇文章可以帮助你实现你自己的自定义View,如果你想要了解更多的内容,可以看这个视频(Youtube,需翻墙)

也可以看看这个博客:Lemberg blog