自定义绘制

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

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

替换 onDraw()

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

但是,在调用任何绘制方法之前,必须先创建 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);
    }