compose-graphics について
Claude Code、claude.ai、Cowork向けのクリエイティブコーディングスキルで、Android Jetpack Composeの高度なビジュアルを扱います。コードを書く前にインタラクションの方針を提示し、承認を得ます。
主な機能
- 01Material 3 Expressiveのスプリングモーション
- 02AGSLシェーダー効果
- 03Canvasとジェネレーティブ描画
- 04グラフィックスレイヤー効果とぼかし
- 05コーディング前の方針提示
活用シーン
- 01重要なUI場面の弾む動きの設計
- 02タッチリップルやホログラフィックグラデーションの作成
- 03Canvasによるパーティクルやパターンの描画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ athevon/ genjutsu --skill compose-graphicsインストール後、Claude Code で/compose-graphicsを入力して読み込まれたか確認します。
詳細
バージョン依存の情報です。 以下の API 名、SDK の要件、ブラウザーのサポートに関する記述は、 いずれも 2026-09-08 に一次情報で確認済みです。確認に使用した情報源と確認日は
_jutsu/VERSIONS.mdに記載しています。この日付が古い場合は、バージョン番号をもとに作業する前に再確認してください。
Compose Graphics
高度な Compose ビジュアル:M3 Expressive のモーション物理、AGSL シェーダー(Android 13 以降)、Canvas / DrawScope、graphicsLayer のエフェクト。 高度なテーマ(シェーダー、エクスプレッシブ、M3 Expressive、AGSL、Canvas、ホログラフィック)向けに読み込まれます。 基礎については
../compose-motion/SKILL.mdを参照してください。簡潔なルールをここに記載しています。詳しくはreferences/を参照してください。
選択ガイド:ニーズに応じた API
| ニーズ | API |
|---|---|
| バウンスやオーバーシュートを伴うスプリング物理 | MotionScheme.expressive()(M3 Expressive) |
| ピクセル単位のシェーダー | RuntimeShader + Modifier.graphicsLayer { renderEffect = ... }(Android 13 以降) |
| 生成的な描画(パス、パーティクル、フラクタル) | Canvas { drawScope -> ... } |
| GPU エフェクト(ぼかし、シャドウ、カラー フィルター) | Modifier.graphicsLayer { renderEffect = ... } または Modifier.blur(...) |
| システムに適応するマテリアル(Material You のグラスモーフィズム) | Modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest) |
| Android でのリキッドグラス | AGSL シェーダーのレシピ(iOS のようなネイティブ API はまだありません) |
ドメイン 1:Material 3 Expressive
概要
2025 年の Material 3 の進化では、固定時間のトゥイーンに代わり、スプリングベースのモーション物理が導入されています。androidx.graphics.shapes による新しいシェイプ変形 API も追加されています。テーマで選択できる新しい MotionScheme も導入されました。ヒーロー演出、重要なインタラクション、ブランドを印象づける UI を対象としています。
MotionScheme
| スキーム | 特徴 | 用途 |
|---|---|---|
MotionScheme.standard() | より落ち着いており、オーバーシュートが少ない | クローム、リスト、ナビゲーションのデフォルト |
MotionScheme.expressive() | オーバーシュートが大きく、収束に時間がかかる | ヒーロー演出、FAB、主要な CTA |
テーマに適用します:
MaterialTheme(motionScheme = MotionScheme.expressive()) {
// children read tokens via MaterialTheme.motionScheme.*
}
公開されているトークン:
| トークン | 対象 | スプリング(減衰 / 剛性)- standard | ... expressive |
|---|---|---|---|
fastSpatialSpec() | 位置 / サイズ | 0.9 / 1400 | 0.6 / 800 |
defaultSpatialSpec() | 位置 / サイズ | 0.9 / 700 | 0.8 / 380 |
slowSpatialSpec() | 位置 / サイズ | 0.9 / 300 | 0.8 / 200 |
fastEffectsSpec() | 不透明度 / 色 | 1.0 / 3800 | 1.0 / 3800 |
defaultEffectsSpec() | 不透明度 / 色 | 1.0 / 1600 | 1.0 / 1600 |
slowEffectsSpec() | 不透明度 / 色 | 1.0 / 800 | 1.0 / 800 |
Spatial と Effects の違い: spatial は物理的な要素(高さ、オフセット、スケール)です。Effects は慣性を伴わない視覚プロパティ(アルファ、色、標高)です。どちらの 系統もスプリングですが、Effects のスプリングは臨界減衰(
dampingRatio = 1.0)で剛性がかなり高いため、オーバーシュートしません。standard()とexpressive()で異なるのは spatial のスプリングだけです。
利用可能状況:
MotionScheme、MaterialTheme(motionScheme = ...)、MaterialExpressiveThemeは material3 1.4.0 で安定版 です(オプトインは不要です)。MaterialShapesとシェイプ変形ヘルパーは安定版ではなく、1.5.0-alpha と@OptIn(ExperimentalMaterial3ExpressiveApi::class)が必要です。
ヒーローカードの展開(Expressive Springs)
@Composable
fun ExpressiveHero() {
var expanded by remember { mutableStateOf(false) }
MaterialTheme(motionScheme = MotionScheme.expressive()) {
val transition = updateTransition(targetState = expanded, label = "expand")
val height by transition.animateDp(
transitionSpec = { MaterialTheme.motionScheme.slowSpatialSpec() },
label = "height"
) { if (it) 400.dp else 100.dp }
val alpha by transition.animateFloat(
transitionSpec = { MaterialTheme.motionScheme.defaultEffectsSpec() },
label = "alpha"
) { if (it) 1f else 0f }
Card(
modifier = Modifier
.fillMaxWidth()
.height(height)
.clickable { expanded = !expanded }
) {
Box(modifier = Modifier.alpha(alpha)) {
Text("Detail content", modifier = Modifier.padding(24.dp))
}
}
}
}
スプリングの調整レシピ
| 雰囲気 | 仕様 |
|---|---|
| ヒーロー演出 | spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy) |
| きびきびとしたエクスプレッシブな動き | spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = 0.7f) |
| 落ち着いた空間的な動き | MaterialTheme.motionScheme.defaultSpatialSpec()(トークンを使用) |
| 臨界減衰(オーバーシュートなし) | spring(stiffness = Spring.StiffnessHigh, dampingRatio = 1f) |
シェイプモーフィング(MaterialShapes + Morph)
異なる2つのライブラリを使います。この違いは重要です。
androidx.graphics.shapes:graphics-shapes(安定版 1.1.0)には、RoundedPolygon、CornerRounding、Morph(start, end)が含まれています。Morph.toPath(progress)が返すのはandroid.graphics.Pathです。- 定義済みの Material シェイプ(
MaterialShapes.Circle、Pentagon、Cookie4Sided、Sunny、Heartなど)は、graphics-shapes ではなく material3 に含まれています。これらは現在も@ExperimentalMaterial3ExpressiveApiであり、androidx.compose.material3:material3:1.5.0-alpha*でのみ提供されています。安定版の material3 1.4.0 には 含まれていません。material3 には、Compose のPathを返すMorph.toPath(progress)と、RoundedPolygon.toShape()も追加されています。
.asAndroidPath().asComposePath() は絶対に使わないでください。graphics-shapes のインポートではコンパイルできません(android.graphics.Path には asAndroidPath() がありません)。material3 のインポートでは、無意味な往復変換になります。また、MaterialShapes は 0..1 のボックスに正規化されているため、パスを描画サイズに合わせてスケーリングする必要があります。生のパスを GenericShape に渡すと、左上隅に約1pxのシェイプが表示されます。
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.toPath // Morph.toPath -> androidx.compose.ui.graphics.Path
import androidx.graphics.shapes.Morph
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MorphingBadge(active: Boolean) {
val morph = remember { Morph(MaterialShapes.Circle, MaterialShapes.Cookie4Sided) }
val progress by animateFloatAsState(
targetValue = if (active) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.slowSpatialSpec(),
label = "morph",
)
Box(
modifier = Modifier
.size(96.dp)
.clip(MorphShape(morph, progress))
.background(MaterialTheme.colorScheme.primary)
)
}
/** MaterialShapes are normalized to 0..1: scale the path to the draw size, then centre it. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private class MorphShape(private val morph: Morph, private val progress: Float) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val path = morph.toPath(progress = progress)
path.transform(Matrix().apply { scale(x = size.width, y = size.height) })
path.translate(size.center - path.getBounds().center)
return Outline.Generic(path)
}
}
モーフィングしない静的なシェイプには、これらの処理は不要です。MaterialShapes.Cookie4Sided.toShape() は、スケーリングに対応した、そのまま使える Shape を返します。
Expressive と Standard の使い分け
- Expressive: 見せ場となる演出、重要なインタラクション、FAB、主要な CTA に使います。UI 全体の1~3%を目安にしてください。
- Standard: それ以外のアプリ全体のデフォルトとして使います。Expressive を使いすぎるとまとまりがなくなり、すべての要素が目立とうとしてしまいます。
ドメイン2:AGSL シェーダー(Android 13 以降)
概要
AGSL は Android のシェーダー言語です。簡略化された GLSL に似ており(vec4 の代わりに half4 を使い、機能が制限され、サンドボックスで安全に実行できます)、RuntimeShader が AGSL ソースをコンパイルします。Modifier.graphicsLayer { renderEffect = ... } で Compose のモディファイアにバインドします。
セットアップ
@Composable
fun ShaderEffect(content: @Composable () -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// Android 12 and below: skip the shader, render content as-is.
content()
return
}
val shader = remember { RuntimeShader(AGSL_SOURCE) }
val time by produceState(0f) {
while (true) {
withFrameMillis { ms ->
value = ms / 1000f
}
}
}
Box(
modifier = Modifier
.onSizeChanged {
shader.setFloatUniform("resolution", it.width.toFloat(), it.height.toFloat())
}
.graphicsLayer {
shader.setFloatUniform("time", time)
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
}
) { content() }
}
必ず
Build.VERSION.SDK_INT >= 33を確認してください。 Android 13 より前のフォールバックでは、加工せずにコンテンツを描画するか、Modifier.blur()/静的なグラデーションオーバーレイを使用します。
レシピ:タッチリップル
AGSL ソース:
// ripple.agsl
uniform float2 resolution;
uniform float2 origin;
uniform float time;
uniform shader image;
half4 main(float2 fragCoord) {
float2 toOrigin = fragCoord - origin;
float dist = length(toOrigin);
float wave = sin(dist * 0.05 - time * 8.0) * 0.05;
float falloff = 1.0 / max(dist * 0.01, 1.0);
float2 dir = toOrigin / max(dist, 0.0001);
float2 displaced = fragCoord + dir * wave * falloff * 50.0;
return image.eval(displaced);
}
Compose でのバインド(状態に基づく time。LaunchedEffect でアニメーション化):
@Composable
fun RippleSurface(content: @Composable BoxScope.() -> Unit) {
if (Build.VERSION.SDK_INT < 33) { Box { content() }; return }
val shader = remember { RuntimeShader(RIPPLE_AGSL) }
var origin by remember { mutableStateOf(Offset.Zero) }
var startMs by remember { mutableStateOf<Long?>(null) }
val time by produceState(0f, startMs) {
if (startMs == null) { value = 0f; return@produceState }
val begin = startMs!!
while (true) {
withFrameMillis { now ->
value = (now - begin) / 1000f
}
}
}
Box(
modifier = Modifier
.onSizeChanged {
shader.setFloatUniform("resolution", it.width.toFloat(), it.height.toFloat())
}
.pointerInput(Unit) {
detectTapGestures { tap ->
origin = tap
startMs = System.currentTimeMillis()
}
}
.graphicsLayer {
shader.setFloatUniform("origin", origin.x, origin.y)
shader.setFloatUniform("time", time)
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
},
content = content
)
}
レシピ:ホログラフィックグラデーション
uniform float time;
uniform shader image;
half4 main(float2 fragCoord) {
half4 color = image.eval(fragCoord);
float n = fragCoord.x * 0.01 + fragCoord.y * 0.005 + time * 0.3;
half3 rainbow = half3(
sin(n * 2.0) * 0.5 + 0.5,
sin(n * 2.0 + 2.094) * 0.5 + 0.5,
sin(n * 2.0 + 4.188) * 0.5 + 0.5
);
half luma = dot(color.rgb, half3(0.299, 0.587, 0.114));
return half4(mix(color.rgb, rainbow * luma * 2.0, 0.5), color.a);
}
背後にあるビューの輝度に、位相をずらした RGB の正弦波を乗算します。テキストやアイコンにきらめきを加えます。time で波の移動をアニメーション化します。
レシピ:リキッドグラス/グラスモーフィズム
Android には iOS 26 風のネイティブな glassEffect がありません。ぼかし+色収差+柔らかなエッジの色合いで Compose します。
uniform float2 resolution;
uniform shader image;
half4 main(float2 fragCoord) {
float2 uv = fragCoord / resolution;
// Chromatic aberration: sample R, G, B at slightly different offsets
float2 ca = (uv - 0.5) * 0.004;
half r = image.eval(fragCoord + ca * resolution).r;
half g = image.eval(fragCoord).g;
half b = image.eval(fragCoord - ca * resolution).b;
half4 base = half4(r, g, b, 1.0);
// Soft edge tint: brighten near borders for that frosted look
float edge = smoothstep(0.45, 0.5, max(abs(uv.x - 0.5), abs(uv.y - 0.5)));
half3 tinted = base.rgb + half3(edge * 0.08);
return half4(tinted, 0.85);
}
実際のぼかしを適用するには、上流に Modifier.blur(20.dp, BlurredEdgeTreatment.Unbounded) を組み合わせてください(AGSL で計算するより低コストです)。
パフォーマンスに関する注意
RuntimeShader1つにつきレンダリングパスが1回発生します。60fps で複数を重ねる場合は、Macrobenchmark でベンチマークしてください。image.eval(coord)は、背後にあるビューをテクスチャとしてサンプリングします。ビューが複雑な場合(長いリストやネストされたレイアウトなど)は負荷が高くなります。再コンポーズされない親で静的な部分をキャッシュしてください。- Android 12 以下では、静的画像を使うなど、シェーダーなしで適切にフォールバックしてください。クラッシュさせないでください。
- シェーダーを4つ直列に連結するより、よく練られたシェーダーを1つ使うことをおすすめします。
ドメイン3:Canvas/DrawScope
Canvas API
Canvas(modifier = Modifier.size(200.dp)) {
// 'this' is DrawScope - size, drawCircle, drawPath, drawRect...
drawCircle(Color.Blue, radius = size.minDimension / 2)
drawPath(myPath, color = Color.White, style = Stroke(width = 4.dp.toPx()))
}
| DrawScope のメソッド | 用途 |
|---|---|
drawCircle | 塗りつぶし円またはストローク円 |
drawRect | 長方形。必要に応じてグラデーションブラシも使用できます |
drawPath | Stroke または Fill で任意のパスを描画します |
drawArc | 楕円の一部。進捗を表す円弧に便利です |
drawLine | 2つの Offset 間の直線 |
drawText | TextMeasurer を使ったテキスト描画(推奨)、または drawIntoCanvas { it.nativeCanvas.drawText(...) } |
drawIntoCanvas { } | 生の android.graphics.Canvas にアクセスするための手段 |
レシピ:アニメーションする正弦波
@Composable
fun AnimatedWave() {
val infinite = rememberInfiniteTransition(label = "wave")
val phase by infinite.animateFloat(
initialValue = 0f,
targetValue = 2 * PI.toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(3000, easing = LinearEasing)
),
label = "phase"
)
Canvas(modifier = Modifier.fillMaxWidth().height(80.dp)) {
val path = Path()
var x = 0
while (x <= size.width.toInt()) {
val y = size.height / 2 + sin(x * 0.05f + phase) * 20f
if (x == 0) path.moveTo(x.toFloat(), y) else path.lineTo(x.toFloat(), y)
x += 2
}
drawPath(path, color = Color.Blue, style = Stroke(width = 2.dp.toPx()))
}
}
レシピ:パーティクルシステム(50個のパーティクル)
data class Particle(var x: Float, var y: Float, var vx: Float, var vy: Float, var life: Float)
@Composable
fun ParticleField() {
val particles = remember {
mutableStateListOf<Particle>().apply {
repeat(50) {
add(Particle(
x = Random.nextFloat() * 1000f,
y = Random.nextFloat() * 1000f,
vx = (Random.nextFloat() - 0.5f) * 4f,
vy = (Random.nextFloat() - 0.5f) * 4f,
life = 1f
))
}
}
}
LaunchedEffect(Unit) {
while (true) {
withFrameMillis { /* tick */ }
particles.forEachIndexed { i, p ->
p.x += p.vx
p.y += p.vy
p.life -= 0.01f
if (p.life <= 0f) {
p.x = Random.nextFloat() * 1000f
p.y = Random.nextFloat() * 1000f
p.life = 1f
}
}
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
particles.forEach { p ->
drawCircle(
color = Color.White.copy(alpha = p.life),
radius = 2.dp.toPx(),
center = Offset(p.x, p.y)
)
}
}
}
レシピ:生成的なパターン(フローフィールド)
簡単に触れると、ノイズ駆動のパーティクルモーションです。詳しい解説(パーリンノイズ、パーティクルプール、移流ループ)については、references/canvas-generative.md を参照してください。
パフォーマンスに関する注意点
- Canvas は、読み取る状態が変化するたびに再描画されます。変化しやすい状態を
derivedStateOfでラップし、再描画を制御してください。 - 60fps であれば、
LaunchedEffect(Unit) { while(true) { withFrameMillis { ... } } }で問題ありません。delay(16)はタイミングがずれるため、避けてください。 - 負荷の高い生成処理では、パスを一度だけ
rememberで事前計算し、translate { drawPath(...) }で変換のみをアニメーションさせます。 - 1 フレームあたり 200 回の単純な描画なら、ほとんどのデバイスで問題ありません。2000 回を超えると、遅延が発生し始めます。
アンチパターン
1. あらゆる場所で Expressive を使用する
// BAD - every interaction overshoots, UI feels like a bouncy castle
MaterialTheme(motionScheme = MotionScheme.expressive()) {
AppRoot()
}
// GOOD - Standard for chrome, scope Expressive to hero moments
MaterialTheme(motionScheme = MotionScheme.standard()) {
NavigationScaffold {
// Hero detail screen overrides locally
MaterialTheme(motionScheme = MotionScheme.expressive()) {
HeroDetail()
}
}
}
2. API の条件確認なしで RuntimeShader を使用する
// BAD - crashes on Android 12 and below
val shader = remember { RuntimeShader(AGSL_SOURCE) }
// GOOD - gate on SDK level, provide fallback
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ShaderEffect { content() }
} else {
Box(modifier = Modifier.background(fallbackGradient)) { content() }
}
3. Canvas から各フレームで状態を直接読み取る
// BAD - any state change in the parent triggers a recompose of the Canvas
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(color = if (viewModel.isActive) Color.Red else Color.Blue, radius = 50f)
}
// GOOD - hoist the reads, derive a stable value
val color by remember { derivedStateOf { if (viewModel.isActive) Color.Red else Color.Blue } }
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(color = color, radius = 50f)
}
4. AGSL シェーダーを 4 つ連続してつなぐ
// BAD - 4 render passes per frame, GPU melts on mid-range devices
.graphicsLayer { renderEffect = blurEffect }
.graphicsLayer { renderEffect = chromaticEffect }
.graphicsLayer { renderEffect = noiseEffect }
.graphicsLayer { renderEffect = vignetteEffect }
// GOOD - one shader doing all the math in a single pass
.graphicsLayer { renderEffect = combinedGlassEffect }
5. DrawScope 内で割り当てを行う
// BAD - new Path every frame, GC stutter
Canvas(modifier = Modifier.fillMaxSize()) {
val path = Path()
points.forEach { path.lineTo(it.x, it.y) }
drawPath(path, color = Color.Black)
}
// GOOD - reuse a remembered Path, rewind each frame
val path = remember { Path() }
Canvas(modifier = Modifier.fillMaxSize()) {
path.rewind()
points.forEach { path.lineTo(it.x, it.y) }
drawPath(path, color = Color.Black)
}
クイックリファレンス:サブリソースの読み込み
| 必要なもの | 読み込むもの |
|---|---|
| AGSL のレシピ(バインディングコード付きの動作するシェーダー 7 種) | references/agsl-recipes.md |
| M3 Expressive のコレオグラフィーの詳細解説 | references/m3-expressive-deep.md |
| 生成描画パターン(フローフィールド、L-system、パーティクル) | references/canvas-generative.md |
| 基本的なアニメーション(animateAsState、AnimatedVisibility、Transition) | ../compose-motion/SKILL.md |
| CMP パターン(iOS/Android/Desktop 向けの共有 UI) | ../compose-multiplatform/SKILL.md |
参考資料
- drinkthestars/shady - Compose でレンダリングされる AGSL シェーダー
- Mortd3kay/liquid-glass-android
- JumpingKeyCaps/DynamicVisualEffectsAGSL
- Material 3 Expressive のブログ
- Material Design 3 のモーション仕様
- Android AGSL のドキュメント
- androidx.graphics.shapes - シェイプのモーフィング