创建自定义绘图

尝试 Compose 方法
Jetpack Compose 是 Android 推荐的 UI 工具包。了解如何在 Compose 中使用布局。

自定义视图最重要的部分是其外观。根据应用程序的需要,自定义绘图可以很简单或很复杂。本文档介绍了一些最常见的操作。

有关更多信息,请参阅 可绘制对象概述

覆盖 onDraw()

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

从创建 Paint 对象开始。下一部分将更详细地讨论 Paint

创建绘图对象

android.graphics 框架将绘图划分为两个区域

  • 绘制什么,由 Canvas 处理。
  • 如何绘制,由 Paint 处理。

例如, Canvas 提供了用于绘制线条的函数,而 Paint 提供了用于定义该线条的颜色函数。 Canvas 有一个用于绘制矩形的函数,而 Paint 定义了是否用颜色填充该矩形或将其保留为空。 Canvas 定义了您可以在屏幕上绘制的形状,而 Paint 定义了您绘制的每个形状的颜色、样式、字体等。

在您绘制任何内容之前,请创建一个或多个 Paint 对象。以下示例在一个名为 init 的函数中执行此操作。此函数是从 Java 中的构造函数调用的,但它可以在 Kotlin 中内联初始化。

Kotlin

@ColorInt
private var textColor    // Obtained from style attributes.

@Dimension
private var textHeight   // Obtained from style attributes.

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 Paint textPaint;
private Paint piePaint;
private Paint shadowPaint;

@ColorInt
private int textColor;       // Obtained from style attributes.

@Dimension
private float textHeight;    // Obtained from style attributes.

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() 函数中创建绘图对象会显著降低性能,并可能导致 UI 滞后。

处理布局事件

为了正确绘制自定义视图,请找出它的尺寸。复杂的自定义视图通常需要根据其在屏幕上的区域的尺寸和形状执行多个布局计算。切勿假设您在屏幕上看到的视图的尺寸。即使只有一个应用程序使用您的视图,该应用程序也需要处理不同的屏幕尺寸、多个屏幕密度以及纵向模式和横向模式下的各种纵横比。

虽然 View 有许多用于处理测量的函数,但其中大多数不需要被覆盖。如果您的视图不需要特殊控制其尺寸,只需覆盖一个函数: onSizeChanged()

onSizeChanged() 在您的视图首次被分配尺寸时调用,并且如果您的视图的尺寸因任何原因发生变化,也会再次调用。在 onSizeChanged() 中计算与视图尺寸相关的坐标、维度以及任何其他值,而不是在每次绘制时重新计算它们。在以下示例中, onSizeChanged() 是视图计算图表边界矩形以及文本标签和其他视觉元素的相对位置的地方。

当您的视图被分配尺寸时,布局管理器假设该尺寸包含视图的填充。在您计算视图的尺寸时,请处理填充值。以下来自 onSizeChanged() 的代码段展示了如何执行此操作

Kotlin

private val showText    // Obtained from styled attributes.
private val textWidth   // Obtained from styled attributes.

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    // Account for padding.
    var xpad = (paddingLeft + paddingRight).toFloat()
    val ypad = (paddingTop + paddingBottom).toFloat()

    // Account for the label.
    if (showText) xpad += textWidth.toFloat()
    val ww = w.toFloat() - xpad
    val hh = h.toFloat() - ypad

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

Java

private Boolean showText;    // Obtained from styled attributes.
private int textWidth;       // Obtained from styled attributes.

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    // 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 you can make the pie.
    float diameter = Math.min(ww, hh);
}

如果您需要对视图的布局参数进行更精细的控制,请实现 onMeasure()。该函数的参数是 View.MeasureSpec 值,它们告诉您视图的父级希望视图有多大,以及该尺寸是硬性最大值还是仅仅是建议。为了优化,这些值以打包整数的形式存储,您可以使用 View.MeasureSpec 的静态函数来解包存储在每个整数中的信息。

以下是如何实现 onMeasure() 的示例。在此实现中,它试图使其区域足够大以使图表与标签一样大

Kotlin

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

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

    setMeasuredDimension(w, h)
}

Java

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

   // Whatever the width is, ask for a height that lets the pie get as big as it
   // can.
   int minh = MeasureSpec.getSize(w) - (int)textWidth + getPaddingBottom() + getPaddingTop();
   int h = resolveSizeAndState(minh, heightMeasureSpec, 0);

   setMeasuredDimension(w, h);
}

在该代码中需要注意三点

  • 计算考虑了视图的填充。如前所述,这是视图的责任。
  • 辅助函数 resolveSizeAndState() 用于创建最终的宽度和高度值。此辅助函数通过将视图所需的尺寸与传递到 onMeasure() 中的值进行比较来返回适当的 View.MeasureSpec 值。
  • onMeasure() 没有返回值。相反,该函数通过调用 setMeasuredDimension() 来传达其结果。调用此函数是强制性的。如果您省略了此调用, View 类会抛出运行时异常。

绘制

定义了对象创建和测量代码后,就可以实现 onDraw()。每个视图都以不同的方式实现 onDraw(),但大多数视图都有一些通用的操作

  • 使用 drawText() 绘制文本。通过调用 setTypeface() 来指定字体,并通过调用 setColor() 来指定文本颜色。
  • 使用 drawRect()drawOval()drawArc() 绘制基本形状。通过调用 setStyle() 来更改形状是填充、轮廓还是两者兼而有之。
  • 使用 Path 类绘制更复杂的形状。通过向 Path 对象添加线条和曲线来定义形状,然后使用 drawPath() 绘制形状。与基本形状一样,路径可以根据 setStyle() 是轮廓、填充还是两者兼而有之。
  • 通过创建 LinearGradient 对象来定义渐变填充。调用 setShader() 以在填充形状上使用您的 LinearGradient
  • 使用 drawBitmap() 绘制位图。

以下代码绘制了文本、线条和形状的组合

Kotlin

private val data = mutableListOf<Item>() // A list of items that are displayed.

private var shadowBounds = RectF()       // Calculated in onSizeChanged.
private var pointerRadius: Float = 2f    // Obtained from styled attributes.
private var pointerX: Float = 0f         // Calculated in onSizeChanged.
private var pointerY: Float = 0f         // Calculated in onSizeChanged.
private var textX: Float = 0f            // Calculated in onSizeChanged.
private var textY: Float = 0f            // Calculated in onSizeChanged.
private var bounds = RectF()             // Calculated in onSizeChanged.
private var currentItem: Int = 0         // The index of the currently selected item.

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

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

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

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

        // Draw the pointer.
        drawLine(textX, pointerY, pointerX, pointerY, textPaint)
        drawCircle(pointerX, pointerY, pointerRadius, textPaint)
    }
}

// Maintains the state for a data item.
private data class Item(
    var label: String,      
    var value: Float = 0f,

    @ColorInt
    var color: Int = 0,

    // Computed values.
    var startAngle: Float = 0f,
    var endAngle: Float = 0f,

    var shader: Shader
)

Java

private List<Item> data = new ArrayList<Item>();  // A list of items that are displayed.

private RectF shadowBounds;                       // Calculated in onSizeChanged.
private float pointerRadius;                      // Obtained from styled attributes.
private float pointerX;                           // Calculated in onSizeChanged.
private float pointerY;                           // Calculated in onSizeChanged.
private float textX;                              // Calculated in onSizeChanged.
private float textY;                              // Calculated in onSizeChanged.
private RectF bounds;                             // Calculated in onSizeChanged.
private int currentItem = 0;                      // The index of the currently selected item.

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

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

    // Draw the label text.
    canvas.drawText(data.get(currentItem).label, 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, pointerRadius, textPaint);
}

// Maintains the state for a data item.
private class Item {
    public String label;
    public float value;
    @ColorInt
    public int color;

    // Computed values.
    public int startAngle;
    public int endAngle;

    public Shader shader;
}    

应用图形效果

Android 12(API 级别 31)添加了 RenderEffect 类,它将常见的图形效果(例如模糊、颜色滤镜、Android 着色器效果等)应用于 View 对象和渲染层次结构。您可以将效果组合为链式效果,链式效果包括内部效果和外部效果,或者混合效果。对该功能的支持因设备处理能力而异。

您还可以通过调用 View.setRenderEffect(RenderEffect) 将效果应用于 View 的底层 RenderNode

要实现 RenderEffect 对象,请执行以下操作

view.setRenderEffect(RenderEffect.createBlurEffect(radiusX, radiusY, SHADER_TILE_MODE))

您可以以编程方式创建视图,也可以从 XML 布局中膨胀视图,并使用 视图绑定findViewById() 检索它。