建立自訂繪圖

嘗試 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()擷取檢視畫面。