グラフィック修飾子

Compose には、Canvas コンポーザブルの他にも、カスタム コンテンツの描画に役立つ便利なグラフィック Modifiers があります。これらの修飾子はどのコンポーザブルにも適用できます。

描画修飾子

Compose では、描画コマンドはすべて描画修飾子によって実行されます。Compose には、主要な描画修飾子が 3 つあります。

描画の基本修飾子は drawWithContent です。この場合、コンポーザブルの描画順序と、修飾子内で実行される描画コマンドを決定できます。drawBehind は、drawWithContent の便利なラッパーで、描画順序はコンポーザブルのコンテンツより後になります。drawWithCache は内部で onDrawBehind または onDrawWithContent を呼び出し、その中で作成されたオブジェクトをキャッシュするメカニズムを提供します。

Modifier.drawWithContent: 描画順序を選択

Modifier.drawWithContent を使用すると、コンポーザブルのコンテンツの前または後に DrawScope オペレーションを実行できます。drawContent を呼び出してから、コンポーザブルの実際のコンテンツをレンダリングしてください。この修飾子を使用すると、オペレーションの順序、つまりカスタム描画オペレーションの前と後のどちらでコンテンツを描画するか決めることができます。

たとえば、コンテンツの上に円形グラデーションをレンダリングして UI にライト キーホール効果を作成する場合は、次のようにします。

var pointerOffset by remember {
    mutableStateOf(Offset(0f, 0f))
}
Column(
    modifier = Modifier
        .fillMaxSize()
        .pointerInput("dragging") {
            detectDragGestures { change, dragAmount ->
                pointerOffset += dragAmount
            }
        }
        .onSizeChanged {
            pointerOffset = Offset(it.width / 2f, it.height / 2f)
        }
        .drawWithContent {
            drawContent()
            // draws a fully black area with a small keyhole at pointerOffset that’ll show part of the UI.
            drawRect(
                Brush.radialGradient(
                    listOf(Color.Transparent, Color.Black),
                    center = pointerOffset,
                    radius = 100.dp.toPx(),
                )
            )
        }
) {
    // Your composables here
}

図 1: コンポーザブルの上で Modifier.drawWithContent を使用して、ライトタイプの UI エクスペリエンスを作成。

Modifier.drawBehind: コンポーザブルの背後の描画

Modifier.drawBehind を使用すると、画面に描画されるコンポーザブル コンテンツの背後で DrawScope オペレーションを実行できます。Canvas の実装を見てみると、単に Modifier.drawBehind のラッパーであることがわかります。

Text の後ろに角丸四角形を描画するには、次のようにします。

Text(
    "Hello Compose!",
    modifier = Modifier
        .drawBehind {
            drawRoundRect(
                Color(0xFFBBAAEE),
                cornerRadius = CornerRadius(10.dp.toPx())
            )
        }
        .padding(4.dp)
)

結果は次のようになります。

Modifier.drawBehind で描画されたテキストと背景
図 2: Modifier.drawBehind で描画されたテキストと背景

Modifier.drawWithCache: 描画オブジェクトの描画とキャッシュ

Modifier.drawWithCache は、オブジェクト内で作成されたオブジェクトをキャッシュに保存します。描画領域のサイズが同じであるか、読み取られる状態オブジェクトが変更されていない場合に限り、オブジェクトはキャッシュに保存されます。この修飾子は、描画時に作成されるオブジェクト(Brush, Shader, Path など)を再割り当てする必要がないため、描画呼び出しのパフォーマンスの改善に役立ちます。

あるいは、修飾子の外部にある remember を使用してオブジェクトをキャッシュに保存することもできます。ただし、コンポジションに常にアクセスできるわけではないため、この処理ができない場合もあります。オブジェクトを描画にのみ使用する場合、drawWithCache を使用するとパフォーマンスが向上します。

たとえば、Text の背後にグラデーションを描画する Brush を作成する場合、drawWithCache を使用すると、描画領域のサイズが変更されるまで Brush オブジェクトがキャッシュに保存されます。

Text(
    "Hello Compose!",
    modifier = Modifier
        .drawWithCache {
            val brush = Brush.linearGradient(
                listOf(
                    Color(0xFF9E82F0),
                    Color(0xFF42A5F5)
                )
            )
            onDrawBehind {
                drawRoundRect(
                    brush,
                    cornerRadius = CornerRadius(10.dp.toPx())
                )
            }
        }
)

drawWithCache でブラシ オブジェクトをキャッシュする
図 3: drawWithCache でブラシ オブジェクトをキャッシュする

グラフィック修飾子

Modifier.graphicsLayer: コンポーザブルに変換を適用する

Modifier.graphicsLayer は、コンポーザブルのコンテンツを描画レイヤに描画する修飾子です。レイヤには、次のようないくつかの機能があります。

  • 描画命令の分離(RenderNode と同様)。レイヤの一部としてキャプチャされた描画命令は、アプリケーション コードを再実行しなくても、レンダリング パイプラインによって効率的に再実行できます。
  • レイヤに含まれるすべての描画命令に適用される変換。
  • 合成機能のラスタライズ。レイヤがラスタライズされると、描画命令が実行され、出力が画面外バッファにキャプチャされます。後続のフレーム用にこのようなバッファを合成すると、個々の命令を実行するより短時間で済みますが、スケーリングや回転などの変換が適用されるとビットマップとして動作します。

変換

Modifier.graphicsLayer では、描画の手順が分離されます。たとえば、Modifier.graphicsLayer を使用してさまざまな変換を適用できます。これらは、描画ラムダを再実行することなく、アニメーション化または変更できます。

Modifier.graphicsLayer は描画フェーズにのみ影響するため、コンポーザブルの測定サイズや配置は変更されません。そのため、コンポーザブルがレイアウト境界の外側に描画されると、他のコンポーザブルと重なることがあります。

この修飾子を使用すると、次の変換を適用できます。

スケール - サイズを拡大

scaleXscaleY を使用すると、コンテンツはそれぞれ水平方向または垂直方向に拡大または縮小されます。値が 1.0f の場合、スケーリングの変化はありません。また、値が 0.5f の場合、サイズは半分になります。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.scaleX = 1.2f
            this.scaleY = 0.8f
        }
)

図 4: scaleX と scaleY を Image コンポーザブルに適用
平行移動

translationXtranslationYgraphicsLayer で変更できます。translationX はコンポーザブルを左右に移動します。translationY はコンポーザブルを上下に移動します。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.translationX = 100.dp.toPx()
            this.translationY = 10.dp.toPx()
        }
)

図 5: Modifier.graphicsLayer を使用して Image に TranslationX と TranslationY を適用
回転

rotationX で水平方向の回転、rotationY で垂直方向の回転、rotationZ で Z 軸上での回転(標準の回転)を設定します。この値は度単位(0~360)で指定します。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.rotationX = 90f
            this.rotationY = 275f
            this.rotationZ = 180f
        }
)

図 6: Modifier.graphicsLayer により Image に rotationX、rotationY、rotationZ を設定
原点

transformOrigin を指定できます。その後、この点を原点として変換を実行できます。これまでの例ではすべて TransformOrigin.Center を使用していました。これは (0.5f, 0.5f) に位置します。原点を (0f, 0f) に指定すると、変換はコンポーザブルの左上の隅から開始されます。

rotationZ 変換で原点を変更すると、コンポーザブルの左上でアイテムが回転します。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.transformOrigin = TransformOrigin(0f, 0f)
            this.rotationX = 90f
            this.rotationY = 275f
            this.rotationZ = 180f
        }
)

図 7: TransformOrigin を (0f, 0f) に設定して回転を適用

クリップと図形

形状とは、clip = true の場合にコンテンツがクリップされる輪郭を指します。この例では、2 つのボックスを設定して、2 つの異なるクリップを作成します。1 つは graphicsLayer クリップ変数を使用し、もう 1 つは便利なラッパー Modifier.clip を使用します。

Column(modifier = Modifier.padding(16.dp)) {
    Box(
        modifier = Modifier
            .size(200.dp)
            .graphicsLayer {
                clip = true
                shape = CircleShape
            }
            .background(Color(0xFFF06292))
    ) {
        Text(
            "Hello Compose",
            style = TextStyle(color = Color.Black, fontSize = 46.sp),
            modifier = Modifier.align(Alignment.Center)
        )
    }
    Box(
        modifier = Modifier
            .size(200.dp)
            .clip(CircleShape)
            .background(Color(0xFF4DB6AC))
    )
}

最初のボックス(テキスト「Hello Compose」)が円形に切り取られます。

Box コンポーザブルにクリップを適用
図 8: Box コンポーザブルにクリップを適用

上のピンク色の円に translationY を適用すると、コンポーザブルの境界は同じままですが、この円は下の円より下(とその境界の外側)に描画されます。

translationY と輪郭用の赤色の境界線を設定してクリップを適用
図 9: translationY と輪郭用の赤色の境界線を設定してクリップを適用

コンポーザブルを描画領域にクリップするには、修飾子チェーンの先頭に別の Modifier.clip(RectangleShape) を追加します。コンテンツは元の境界内に残ります。

Column(modifier = Modifier.padding(16.dp)) {
    Box(
        modifier = Modifier
            .clip(RectangleShape)
            .size(200.dp)
            .border(2.dp, Color.Black)
            .graphicsLayer {
                clip = true
                shape = CircleShape
                translationY = 50.dp.toPx()
            }
            .background(Color(0xFFF06292))
    ) {
        Text(
            "Hello Compose",
            style = TextStyle(color = Color.Black, fontSize = 46.sp),
            modifier = Modifier.align(Alignment.Center)
        )
    }

    Box(
        modifier = Modifier
            .size(200.dp)
            .clip(RoundedCornerShape(500.dp))
            .background(Color(0xFF4DB6AC))
    )
}

graphicsLayer 変換の上にクリップを適用
図 10: graphicsLayer 変換の上にクリップを適用

アルファ

Modifier.graphicsLayer を使用すると、レイヤ全体の alpha(不透明度)を設定できます。1.0f は完全に不透明で、0.0f は完全に透明になります。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "clock",
    modifier = Modifier
        .graphicsLayer {
            this.alpha = 0.5f
        }
)

アルファを適用した画像
図 11: アルファを適用した画像

合成戦略

アルファと透明度の取り扱いは、単一のアルファ値を変更する場合ほど単純ではないことがあります。アルファを変更するだけでなく、graphicsLayerCompositingStrategy を設定することもできます。CompositingStrategy は、コンポーザブルのコンテンツを、すでに画面に描画されている他のコンテンツとどのように合成する(まとめる)かを決定します。

戦略には次のようなものがあります。

自動(デフォルト)

合成戦略は、graphicsLayer の残りのパラメータによって決まります。アルファが 1.0f 未満であるか、RenderEffect が設定されている場合、レイヤが画面外バッファにレンダリングされます。アルファが 1f 未満の場合は、コンテンツのレンダリングのために合成レイヤが自動的に作成され、対応するアルファを使用して目的の場所にこの画面外バッファが描画されます。RenderEffect またはオーバースクロールを設定すると、CompositingStrategy の設定にかかわらず、コンテンツが常に画面外バッファにレンダリングされます。

画面外

コンポーザブルのコンテンツは、目的の場所にレンダリングする前に常に画面外のテクスチャまたはビットマップにラスタライズされます。これは、BlendMode オペレーションを適用してコンテンツをマスクし、複雑な描画手順のセットをレンダリングする場合に便利です。

CompositingStrategy.Offscreen は、たとえば、BlendModes とあわせて使用できます。以下の例では、BlendMode.Clear を使用する描画コマンドを実行して Image コンポーザブルを部分的に削除できます。compositingStrategyCompositingStrategy.Offscreen に設定しない場合、BlendMode はその下にあるすべてのコンテンツを操作します。

Image(painter = painterResource(id = R.drawable.dog),
   contentDescription = "Dog",
   contentScale = ContentScale.Crop,
   modifier = Modifier
       .size(120.dp)
       .aspectRatio(1f)
       .background(
           Brush.linearGradient(
               listOf(
                   Color(0xFFC5E1A5),
                   Color(0xFF80DEEA)
               )
           )
       )
       .padding(8.dp)
       .graphicsLayer {
           compositingStrategy = CompositingStrategy.Offscreen
       }
       .drawWithCache {
           val path = Path()
           path.addOval(
               Rect(
                   topLeft = Offset.Zero,
                   bottomRight = Offset(size.width, size.height)
               )
           )
           onDrawWithContent {
               clipPath(path) {
                   // this draws the actual image - if you don't call drawContent, it wont
                   // render anything
                   this@onDrawWithContent.drawContent()
               }
               val dotSize = size.width / 8f
               // Clip a white border for the content
               drawCircle(
                   Color.Black,
                   radius = dotSize,
                   center = Offset(
                       x = size.width - dotSize,
                       y = size.height - dotSize
                   ),
                   blendMode = BlendMode.Clear
               )
               // draw the red circle indication
               drawCircle(
                   Color(0xFFEF5350), radius = dotSize * 0.8f,
                   center = Offset(
                       x = size.width - dotSize,
                       y = size.height - dotSize
                   )
               )
           }

       }
)

CompositingStrategyOffscreen に設定すると、コマンドを実行する画面外テクスチャが作成されます(BlendMode はこのコンポーザブルのコンテンツにのみ適用されます)。その後、すでに画面にレンダリングされているコンテンツの上にレンダリングします。描画済みのコンテンツには影響しません。

アプリ内で BlendMode.Clear を使用し、Modifier.drawWithContent で画像上に円形インジケーターを表示
図 12: アプリ内で BlendMode.Clear と CompositingStrategy.Offscreen を使用し、Modifier.drawWithContent で画像上に円形インジケーターを表示

CompositingStrategy.Offscreen を使用しなかった場合は、BlendMode.Clear を適用した結果、すでに設定されている内容に関係なく、目的の場所すべてのピクセルがクリアされます。このため、ウィンドウのレンダリング バッファ(黒)は残ります。アルファを使用する BlendModes の多くは、画面外バッファがないと想定どおりに機能しません。赤い円形インジケーターの周りにある黒いリングに注目してください。

BlendMode.Clear を使用し、CompositingStrategy を設定せずに、Modifier.drawWithContent で画像上に円形インジケーターを表示
図 13: BlendMode.Clear を使用し、CompositingStrategy を設定せずに、Modifier.drawWithContent で画像上に円形インジケーターを表示

もう少し詳しく見てみましょう。アプリに半透明のウィンドウ背景があり、CompositingStrategy.Offscreen を使用しなかった場合は、BlendMode がアプリ全体を操作します。以下の例に示すように、すべてのピクセルがクリアされ、その下のアプリまたは壁紙が表示されます。

ウィンドウ背景が半透明なアプリで CompositingStrategy を設定せず、BlendMode.Clear を使用。ピンクの壁紙が赤いステータス サークルの周りの領域で表示されます。
図 14: ウィンドウ背景が半透明なアプリで CompositingStrategy を設定せず、BlendMode.Clear を使用。ピンクの壁紙が赤いステータス サークルの周りの領域で表示されます。

CompositingStrategy.Offscreen を使用すると、描画領域のサイズと一致する画面外テクスチャが作成され、画面上にレンダリングされます。デフォルトでは、この戦略により実行された描画コマンドはすべてこの領域にクリップされます。次のコード スニペットは、画面外テクスチャに切り替えた場合の違いを示しています。

@Composable
fun CompositingStrategyExamples() {
   Column(
       modifier = Modifier
           .fillMaxSize()
           .wrapContentSize(Alignment.Center)
   ) {
       /** Does not clip content even with a graphics layer usage here. By default, graphicsLayer
       does not allocate + rasterize content into a separate layer but instead is used
       for isolation. That is draw invalidations made outside of this graphicsLayer will not
       re-record the drawing instructions in this composable as they have not changed **/
       Canvas(
           modifier = Modifier
               .graphicsLayer()
               .size(100.dp) // Note size of 100 dp here
               .border(2.dp, color = Color.Blue)
       ) {
           // ... and drawing a size of 200 dp here outside the bounds
           drawRect(color = Color.Magenta, size = Size(200.dp.toPx(), 200.dp.toPx()))
       }

       Spacer(modifier = Modifier.size(300.dp))

       /** Clips content as alpha usage here creates an offscreen buffer to rasterize content
       into first then draws to the original destination **/
       Canvas(
           modifier = Modifier
               // force to an offscreen buffer
               .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
               .size(100.dp) // Note size of 100 dp here
               .border(2.dp, color = Color.Blue)
       ) {
           /** ... and drawing a size of 200 dp. However, because of the CompositingStrategy.Offscreen usage above, the
           content gets clipped **/
           drawRect(color = Color.Red, size = Size(200.dp.toPx(), 200.dp.toPx()))
       }
   }
}

CompositingStrategy.Auto と CompositingStrategy.Offscreen の比較 - Offscreen は領域にクリップするが、Auto はしない
図 15: CompositingStrategy.Auto と CompositingStrategy.Offscreen の比較 - Offscreen は領域にクリップするが、Auto はしない
ModulateAlpha

この合成戦略は、graphicsLayer 内で記録された各描画命令のアルファを調節します。RenderEffect が設定されていない場合、1.0f 未満のアルファに対して画面外バッファは作成されないため、アルファ レンダリングを効率的に行うことができます。ただし、コンテンツが重なる場合は、結果が異なる可能性があります。コンテンツが重なっていないことが事前にわかっている場合は、アルファ値が 1 未満の CompositingStrategy.Auto よりもパフォーマンスが高くなることがあります。

各種の合成戦略のもう一つの例として、コンポーザブルの各部分に異なるアルファを適用し、Modulate 戦略を適用することがあげられます。

@Preview
@Composable
fun CompositingStratgey_ModulateAlpha() {
  Column(
      modifier = Modifier
          .fillMaxSize()
          .padding(32.dp)
  ) {
      // Base drawing, no alpha applied
      Canvas(
          modifier = Modifier.size(200.dp)
      ) {
          drawSquares()
      }

      Spacer(modifier = Modifier.size(36.dp))

      // Alpha 0.5f applied to whole composable
      Canvas(modifier = Modifier
          .size(200.dp)
          .graphicsLayer {
              alpha = 0.5f
          }) {
          drawSquares()
      }
      Spacer(modifier = Modifier.size(36.dp))

      // 0.75f alpha applied to each draw call when using ModulateAlpha
      Canvas(modifier = Modifier
          .size(200.dp)
          .graphicsLayer {
              compositingStrategy = CompositingStrategy.ModulateAlpha
              alpha = 0.75f
          }) {
          drawSquares()
      }
  }
}

private fun DrawScope.drawSquares() {

  val size = Size(100.dp.toPx(), 100.dp.toPx())
  drawRect(color = Red, size = size)
  drawRect(
      color = Purple, size = size,
      topLeft = Offset(size.width / 4f, size.height / 4f)
  )
  drawRect(
      color = Yellow, size = size,
      topLeft = Offset(size.width / 4f * 2f, size.height / 4f * 2f)
  )
}

val Purple = Color(0xFF7E57C2)
val Yellow = Color(0xFFFFCA28)
val Red = Color(0xFFEF5350)

ModulateAlpha はアルファセットを個々の描画コマンドに適用
図 16: ModulateAlpha はアルファセットを個々の描画コマンドに適用

コンポーザブルの内容をビットマップに書き込む

一般的なユースケースは、コンポーザブルから Bitmap を作成することです。コンポーザブルの内容を Picture にコピーするには、drawIntoCanvas メソッドを使用します。

Column(
    modifier = Modifier
        .padding(padding)
        .fillMaxSize()
        .drawWithCache {
            // Example that shows how to redirect rendering to an Android Picture and then
            // draw the picture into the original destination
            val width = this.size.width.toInt()
            val height = this.size.height.toInt()

            onDrawWithContent {
                val pictureCanvas =
                    androidx.compose.ui.graphics.Canvas(
                        picture.beginRecording(
                            width,
                            height
                        )
                    )
                // requires at least 1.6.0-alpha01+
                draw(this, this.layoutDirection, pictureCanvas, this.size) {
                    this@onDrawWithContent.drawContent()
                }
                picture.endRecording()

                drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
            }
        }

) {
    ScreenContentToCapture()
}

上記のスニペットは、コンポーザブルの内容を Picture オブジェクトに描画しています。次に、PictureBitmap に変換します。

val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    Bitmap.createBitmap(picture)
} else {
    val bitmap = Bitmap.createBitmap(
        picture.width,
        picture.height,
        Bitmap.Config.ARGB_8888
    )
    val canvas = android.graphics.Canvas(bitmap)
    canvas.drawColor(android.graphics.Color.WHITE)
    canvas.drawPicture(picture)
    bitmap
}

ビットマップはディスクに保存して共有できます。詳しくは、スニペット全体をご覧ください。

カスタム描画修飾子

独自のカスタム修飾子を作成するには、DrawModifier インターフェースを実装します。これにより ContentDrawScope にアクセスできるようになりますが、これは Modifier.drawWithContent() の使用時に公開されるものと同じです。次に、一般的な描画オペレーションをカスタム描画修飾子に抽出して、コードをクリーンアップし、便利なラッパーを提供します。たとえば、Modifier.background() は便利な DrawModifier です。

コンテンツを垂直方向に反転する Modifier を実装する場合は、次のように作成します。

class FlippedModifier : DrawModifier {
    override fun ContentDrawScope.draw() {
        scale(1f, -1f) {
            this@draw.drawContent()
        }
    }
}

fun Modifier.flipped() = this.then(FlippedModifier())

次に、この反転修飾子を Text で適用します。

Text(
    "Hello Compose!",
    modifier = Modifier
        .flipped()
)

Text でのカスタム反転修飾子
図 17: Text でのカスタム反転修飾子

参考情報

graphicsLayer とカスタム描画を使用するその他の例については、次のリソースをご覧ください。