Jetpack Compose における DrawModifier の内部構造とレンダリング順序

Jetpack Compose におけるカスタム描画を管理する DrawModifier は、UI レイアウト体系の中でも重要な役割を果たします。本稿では、DrawModifier の実装メカニズムを深掘りし、レイヤー間の依存関係や、コンポーネントがどのように描画されるのかという根本的な原理を解説します。この理解により、複雑な Modifier チェーンにおいて意図した視覚効果を実現するための設計が可能になります。

基本実装の概要

DrawModifier は Modifier.Element インターフェースを継承しており、単一の draw() メソッドのみを実装する必要がある定義です。

@Stable
interface DrawModifier : Modifier.Element {
  fun ContentDrawScope.draw()
}

このインターフェースは、匿名オブジェクトを渡すか、あるいはより簡潔なヘルパー関数を使用して拡張可能です。Modifier.then() を使用する方法:

Modifier.then(object : DrawModifier {
  override fun ContentDrawScope.draw() {
    // ここにカスタム描画ロジック
  }
})

一般的な利用パターンとして提供される drawWithContent() メソッドも実質的には同じ仕組みに基づいています:

Modifier.drawWithContent {
  // カスタム描画処理
}

ここで注意すべき点は、既存のコンテンツを描画した上で追加のグラフィックを重ねる場合、drawContent() を明示的に呼び出す必要があるという制限です。このメソッドを省略すると、下位の_modifier_によって描画された要素が完全に消去されてしまいます。

重要: Modifier の連鎖順による動作の違いを理解するために、以下の 3 つのパターン比較を行います。

  • パターン A:background().size().drawWithContent {} — 背景が上書きされ、結果は透明になる可能性あり。
  • パターン B:size().drawWithContent {}.background()drawWithContent が内部で drawContent() を呼ばないと、右側の修饰子が無効化される。
  • パターン C:size().drawWithContent { drawContent(); }.background()drawContent() を通じて通常のパスを通すことで、後続の背景設定が正しく反映される。

さらに、ContentDrawScope は親インターフェースである DrawScope から多くのプリミティブ描画関数(矩形、円形、ラインなど)を受け継いでいます。そのため、標準的な Canvas 操作と同様のコード記法が可能ですが、テキスト描画などの一部機能については底層の実装へアクセスする必要があります。

レイアウトノードにおける保存メカニズム

Modifier 属性が設定されると、システムはこれを内部的なツリー構造へと再構築します。特に DrawModifier は、LayoutNode に紐づく「エンティティリスト」の特定箇所に登録されます。このプロセスは、レイアウト処理よりも前に完結する必要があります。

具体的には、foldOut() 経由でチェインが展開される際、DrawModifier に対応するデータ構造(DrawEntity)が、現在の LayoutNodeWrapper の配列先頭付近に格納されます。これは「レイアウトより先に描画を扱う」という優先順位を保証するためのものであり、洋葱モデルのように、外側から内側への階層構造を形成しています。

例として、以下の Modifier チェインについて構造を考察してみます:

Box(
  Modifier
    .background(Color.Red)
    .padding(16.dp)
    .background(Color.Blue)
)

このコードが処理されると、内部的なオブジェクト構造は以下のような連想マップを形成します。

  1. InnerPlaceable: 中心となるコンテナノード。
  2. ModifiedLayoutNode: パディング情報を保持し、その中身をラップ。
  3. DrawEntity: 背景色に関連する描画タスクをキューに登録。

各レイヤーは自身の所属する Wrapper に直接関連づけられ、特定の LayoutModifier の直近にある位置に記憶されます。これにより、サイズ計算と描画順序が明確に分離された状態が維持されます。

描画実行フローの詳細

実際のレンダリング時は、outerLayoutNodeWrapper と呼ばれる最上位のノードから順次 draw(canvas) メソッドがトリガーされます。ここでは、独自のレイヤー管理の有無によって分岐が発生します。

レイヤー不使用時のデフォルト動作

通常通り、層分けが行われないケースでは、以下のようなロジックが走ります。

  1. drawContainedDrawModifiers メソッド内で、登録されている DrawEntity のリストを取得。
  2. リストが空でない場合、頭部のエンティティ(ヘッドノード)が指定する描画関数を呼び出す。
  3. リストが空の場合、内部のラップされたラッパーに対して委譲を行う。

ヘッドノードの描画完了後、または途中でのリクエストに応じて drawContent() が発火し、次のリストノードへ制御が移ります。これが連鎖的に続き、最終的に最も内側の InnerPlaceable に到達すると、绘制終了となります。

もしある DrawModifierdrawContent() を呼び忘れた場合、その以降のノードはすべてスキップされ、期待通りの描画結果は得られません。そのため、カスタム描画を実装する際は、必ずチェーンの切断を防ぐための処理を入念に確認する必要があります。

レイヤーを使用する場合

パフォーマンス調整のために ElevatedSurface や特定のアニメーション要件などで独立したレイヤーが必要なケースでは、システムは特殊なパスを実行します。この場合でも、最終的な描画処理自体は上述した drawContainedDrawModifiers を経由して行われるため、基本的な論理フローに変化はありません。違いは、描画先のキャンバスが一度切り替わる点のみです。

事例に基づく挙動の検証

上記の仕組みを確認するために、具体的なコードパターンに対する出力結果を分析します。

重なり順の確認

複数の背景色が定義された場合、右側の修飾子ほど優先度が高くなります。

Box(Modifier.size(100.dp).background(Color.Red).background(Color.Cyan))

このコードの表示結果は青色(Cyan)になります。内部構造上、赤色は先に登録され、青色が後からその上に重なって記録されるため、視覚的にも正しい順序で処理されています。

サイズの決定要因

サイズ指定に関する挙動は、しばしば誤解を招きやすい部分です。以下のケースではどうなるでしょうか。

Box(
  Modifier
    .width(120.dp)
    .background(Color.Gray)
    .height(60.dp)
)

結果として生成されるブロックの高さは 60dp です。これは、高さを決める width(120.dp) よりも後に出現する height(60.dp) というレイアウトモディファイアが、最終的な境界を決定するためです。背景色は、そのすぐ直前のラッパー(ここでは 120dp のボックスを作成しているノード)の文脈内で処理される一方、サイズ制限はより外部のレイヤーで確定されます。

また、padding が絡んだケース:

Box(
  Modifier
    .width(100.dp)
    .padding(10.dp)
    .background(Color.Green)
)

外枠の幅は 100dp で固定され、内部に 10dp の余白が生じます。つまり、描画エリアの有効幅は 80dp となります。左から順に見ていくような直感的な計算とは異なり、実際には右から左へ参照が伝播することで、最終的なクリップ領域が算出されています。

まとめ

  • 描画順: リスト形式で保持されており、最初に定義されたものが最外層(ヘッド)となり、drawContent() 経由で内部へ降りていきます。
  • 座標系:DrawModifier の描画範囲は、最も近い LayoutModifier によって制約を受けます。
  • 連鎖保持: drawContent() の呼び忘れが描画欠落の主な原因となるため、実装時には常にチェーン全体の健全性を意識してください。

タグ: JetpackCompose AndroidGraphics ModifierChain DrawScope AndroidDev

6月2日 19:46 投稿