欢迎参加我们将于 6 月 3 日举行的 #Android11:Beta 版发布会

自定义绘制

自定义视图最重要的部分是外观。绘制自定义视图可能很简单,也可能很复杂,具体取决于应用的需求。本课将介绍一些最常见的操作。

除了本课程,您还可以在画布和可绘制对象中找到其他相关信息。

替换 onDraw()

绘制自定义视图最重要的一步是替换 onDraw() 方法。onDraw() 的参数是 Canvas 对象,视图可以使用该对象绘制本身。Canvas 类定义了绘制文本、线条、位图和许多其他图形基元的方法。您可以在 onDraw() 中使用这些方法创建自定义界面 (UI)。

不过,在调用任何绘制方法之前,必须先创建 Paint 对象。下一节将详细介绍 Paint

创建绘制对象

android.graphics 框架将绘制分为两个方面:

  • 绘制内容,由 Canvas 负责处理
  • 绘制方式,由 Paint 负责处理。

例如,Canvas 提供绘制线条的方法,Paint 则提供定义线条颜色的方法。Canvas 具有绘制矩形的方法,Paint 则定义是在矩形中填充颜色还是留空。简而言之,Canvas 定义您可以在屏幕上绘制的形状,Paint 则定义您绘制的每个形状的颜色、样式和字体等。

因此,在绘制任何形状之前,您需要创建一个或多个 Paint 对象。PieChart 示例在名为 init 的方法中执行此操作,该方法是从 Java 的构造函数调用的,但我们可以在 Kotlin 中进行内联初始化:

Kotlin

    private val textPaint = Paint(ANTI_ALIAS_FLAG).apply {
        color = textColor
        if (textHeight == 0f) {
            textHeight = textSize
        } else {
            textSize = textHeight
        }
    }

    private val piePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.FILL
        textSize = textHeight
    }

    private val shadowPaint = Paint(0).apply {
        color = 0x101010
        maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
    }
    

Java

    private void init() {
       textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
       textPaint.setColor(textColor);
       if (textHeight == 0) {
           textHeight = textPaint.getTextSize();
       } else {
           textPaint.setTextSize(textHeight);
       }

       piePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
       piePaint.setStyle(Paint.Style.FILL);
       piePaint.setTextSize(textHeight);

       shadowPaint = new Paint(0);
       shadowPaint.setColor(0xff101010);
       shadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL));

       ...
    

提前创建对象是一项重要的优化措施。视图会非常频繁地重新绘制,并且许多绘制对象的初始化都需要占用很多资源。在 onDraw() 方法内创建绘制对象会显著降低性能并使界面显得卡顿。

处理布局事件

为了能正确绘制自定义视图,您需要知道它的大小。复杂的自定义视图通常需要执行多种布局计算,具体取决于它们在屏幕上所占区域的大小和形状。不要妄自假设视图在屏幕上的大小。即使只有一个应用使用您的视图,该应用也需要处理纵向和横向模式下的不同屏幕尺寸、多种屏幕密度和各种宽高比。

尽管 View 提供多种测量处理方法,大部分方法都不需要被替换。如果您的视图不需要对其大小进行特殊控制,则只需替换一个方法,即 onSizeChanged()

首次为视图指定大小时,系统会调用 onSizeChanged();如果视图大小因任何原因而改变,则会再次调用该方法。请在 onSizeChanged() 中计算位置、尺寸以及其他与视图大小相关的任何值,而不要在每次绘制时都重新计算。在 PieChart 示例中,PieChart 视图在 onSizeChanged() 中计算饼图的边界矩形,以及文本标签和其他视觉元素的相对位置。

为视图指定大小时,布局管理器会假定其大小包含视图的所有内边距。您必须在计算视图大小时处理内边距值。以下 PieChart.onSizeChanged() 中的代码段演示了如何执行此操作:

Kotlin

    // Account for padding
    var xpad = (paddingLeft + paddingRight).toFloat()
    val ypad = (paddingTop + paddingBottom).toFloat()

    // Account for the label
    if (showText) xpad += textWidth

    val ww = w.toFloat() - xpad
    val hh = h.toFloat() - ypad

    // Figure out how big we can make the pie.
    val diameter = Math.min(ww, hh)
    

Java

    // Account for padding
    float xpad = (float)(getPaddingLeft() + getPaddingRight());
    float ypad = (float)(getPaddingTop() + getPaddingBottom());

    // Account for the label
    if (showText) xpad += textWidth;

    float ww = (float)w - xpad;
    float hh = (float)h - ypad;

    // Figure out how big we can make the pie.
    float diameter = Math.min(ww, hh);
    

如果您需要更精细地控制视图的布局参数,请实现 onMeasure()。此方法的参数为 View.MeasureSpec 值,它会告诉你父级视图对您的视图的大小要求,以及该大小是硬性最大值还是只是建议值。作为优化措施,这些值存储为压缩整数,并且您要使用 View.MeasureSpec 的静态方法解压缩每个整数中存储的信息。

以下是 onMeasure() 的一个示例实现。在此实现中,PieChart 尝试使其面积足够大,以使饼图大小与其标签一致:

Kotlin

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // Try for a width based on our minimum
        val minw: Int = paddingLeft + paddingRight + suggestedMinimumWidth
        val w: Int = View.resolveSizeAndState(minw, widthMeasureSpec, 1)

        // Whatever the width ends up being, ask for a height that would let the pie
        // get as big as it can
        val minh: Int = View.MeasureSpec.getSize(w) - textWidth.toInt() + paddingBottom + paddingTop
        val h: Int = View.resolveSizeAndState(
                View.MeasureSpec.getSize(w) - textWidth.toInt(),
                heightMeasureSpec,
                0
        )

        setMeasuredDimension(w, h)
    }
    

Java

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       // Try for a width based on our minimum
       int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
       int w = resolveSizeAndState(minw, widthMeasureSpec, 1);

       // Whatever the width ends up being, ask for a height that would let the pie
       // get as big as it can
       int minh = MeasureSpec.getSize(w) - (int)mTextWidth + getPaddingBottom() + getPaddingTop();
       int h = resolveSizeAndState(MeasureSpec.getSize(w) - (int)mTextWidth, heightMeasureSpec, 0);

       setMeasuredDimension(w, h);
    }
    

在此代码中,有三点需要注意:

  • 计算时会考虑视图的内边距。如前文所述,由视图负责计算。
  • 辅助方法 resolveSizeAndState() 用于创建最终的宽度和高度值。该辅助程序通过将视图所需大小与传递到 onMeasure() 的规格进行比较,返回合适的 View.MeasureSpec 值。
  • onMeasure() 没有返回值,而是通过调用 setMeasuredDimension() 传达其结果。必须要调用这个方法。如果省略此调用,View 类将抛出运行时异常。

绘制!

创建好对象并定义了测量代码后,您可以实现 onDraw()。每个视图以不同方式实现 onDraw(),但大多数视图共享一些常见的操作:

例如,以下代码绘制了 PieChart。它组合使用了文本、线条和形状。

Kotlin

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        canvas.apply {
            // Draw the shadow
            drawOval(shadowBounds, shadowPaint)

            // Draw the label text
            drawText(data[mCurrentItem].mLabel, textX, textY, textPaint)

            // Draw the pie slices
            data.forEach {
                piePaint.shader = it.mShader
                drawArc(bounds,
                        360 - it.endAngle,
                        it.endAngle - it.startAngle,
                        true, piePaint)
            }

            // Draw the pointer
            drawLine(textX, pointerY, pointerX, pointerY, textPaint)
            drawCircle(pointerX, pointerY, pointerSize, mTextPaint)
        }
    }
    

Java

    protected void onDraw(Canvas canvas) {
       super.onDraw(canvas);

       // Draw the shadow
       canvas.drawOval(
               shadowBounds,
               shadowPaint
       );

       // Draw the label text
       canvas.drawText(data.get(currentItem).mLabel, textX, textY, textPaint);

       // Draw the pie slices
       for (int i = 0; i < data.size(); ++i) {
           Item it = data.get(i);
           piePaint.setShader(it.shader);
           canvas.drawArc(bounds,
                   360 - it.endAngle,
                   it.endAngle - it.startAngle,
                   true, piePaint);
       }

       // Draw the pointer
       canvas.drawLine(textX, pointerY, pointerX, pointerY, textPaint);
       canvas.drawCircle(pointerX, pointerY, pointerSize, mTextPaint);
    }