建立自訂繪圖

試用 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() 擷取檢視區塊。