カスタム図形描画を作成する

Compose をお試しください
Jetpack Compose は、Android で推奨される UI ツールキットです。Compose でレイアウトを扱う方法について説明します。

カスタムビューの最も重要な要素は外観です。カスタム描画では、アプリのニーズに応じて簡単な図形も複雑な図形も作成できます。このドキュメントでは、いくつかの最も一般的なオペレーションを扱います。

詳しくは、 ドローアブルの概要をご覧ください。

onDraw() をオーバーライドする

カスタムビューの描画で最も重要なステップは、onDraw() メソッドのオーバーライドです。onDraw() のパラメータは、ビューが自身を描画する際に使用できる Canvas オブジェクトです。Canvas クラスは、テキストや、線、ビットマップ、その他さまざまなグラフィック プリミティブを描画するためのメソッドを定義します。onDraw() でこうしたメソッドを使用することにより、カスタム ユーザー インターフェース(UI)を作成できます。

まず、Paint オブジェクトを作成します。 次のセクションでは、Paint について詳しく説明します。

描画オブジェクトを作成する

android.graphics フレームワークでは、描画は 2 つの領域に分かれています。

  • 何を描画するか(処理担当は Canvas
  • どのように描画するか(処理担当は Paint

たとえば、Canvas は、線を描画するメソッドを提供し、Paint は、その線の色を定義するメソッドを提供します。 Canvas には、四角形を描画するメソッドがあり、Paint は、その四角形を特定の色で塗りつぶすか空白のままにするかを定義します。 Canvas は、画面上に描画できる図形を定義し、Paint は、描画する図形の色やスタイル、フォントなどを定義します。

何かを描画する前に、1 つ以上の 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 の表示が遅くなることがあります。

レイアウト イベントを処理する

カスタムビューを適切に描画するには、そのサイズを知る必要があります。複雑なカスタムビューでは、多くの場合、画面上の描画領域のサイズと形状に応じて、複数のレイアウト計算を実行する必要があります。画面上のビューのサイズについて、なんらかの仮定を行うべきではありません。ビューを使用するアプリが 1 つしかない場合でも、そのアプリは、縦表示と横表示の両方で、異なる画面サイズ、複数の画面密度、さまざまなアスペクト比を処理する必要があります。

View には測定を処理する多くのメソッドがありますが、それらのほとんどはオーバーライドを必要としません。ビューのサイズを特別に制御する必要がなければ、onSizeChanged() メソッドのみをオーバーライドすれば足ります。

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);
}

このコードには、3 つの重要な注意点があります。

  • 計算では、ビューのパディングが考慮されます。上記で説明したとおり、この処理はビューが行う必要があります。
  • ヘルパー メソッド resolveSizeAndState() を使用して、最終的な幅と高さの値を作成します。このヘルパーは、ビューに必要なサイズと onMeasure() に渡された値を比較することで、適切な View.MeasureSpec 値を返します。
  • onMeasure() には戻り値はありません。その代わりに、このメソッドは setMeasuredDimension() を呼び出して結果を伝えます。 このメソッドの呼び出しは必須です。この呼び出しを省略すると、 View クラスはランタイム例外をスローします。

描画

オブジェクト作成と測定コードを定義したら、onDraw() を実装できます。ビューごとに onDraw() の実装方法は異なりますが、以下に示すように、ほとんどのビューに共通している処理もあります。

  • `drawText()` を使用してテキストを描画します。 setTypeface() を呼び出して書体を指定し、 setColor() を呼び出してテキストの色を指定します。
  • `drawRect()`、`drawOval()`、`drawArc()` を使用してプリミティブな図形を描画します。drawRect()drawOval()drawArc()` setStyle(). ` を呼び出して、図形を塗りつぶすのか、枠線を付けるのか、その両方を行うのかの設定を変更します。
  • クラスを使用して複雑な図形を描画します。PathPath オブジェクトに直線や曲線を追加して図形を定義し、 drawPath() を使用して図形を描画します。 プリミティブな図形と同様、パスを塗りつぶすのか、枠線を付けるのか、その両方を行うのかを、setStyle() によって指定できます。
  • オブジェクトを作成して、グラデーションの塗りつぶしを定義します。LinearGradient を呼び出して、塗りつぶし図形に対して LinearGradient を使用します。setShader()
  • を使用してビットマップを描画します。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)では、ぼかし、カラーフィルタ、Android シェーダー効果などの一般的なグラフィック効果を View オブジェクトやレンダリング階層に適用する RenderEffect クラスが追加されました。効果は、チェーン効果(内部効果と外部効果)またはブレンド効果として組み合わせることができます。この機能のサポートは、デバイスの処理能力によって異なります。

RenderNode の基になる に効果を適用することもできます。ViewView.setRenderEffect(RenderEffect)

RenderEffect オブジェクトを実装するには、次の操作を行います。

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

ビューはプログラムで作成することも、XML レイアウトからインフレートして View binding または findViewById()を使用して取得することもできます。