mcpField
C
compose-graphics

compose-graphics

github.com/athevonデザイン・UI698934 回インストール3 週間前に更新
リポジトリ

compose-graphics について

Claude Code、claude.ai、Cowork向けのクリエイティブコーディングスキルで、Android Jetpack Composeの高度なビジュアルを扱います。コードを書く前にインタラクションの方針を提示し、承認を得ます。

主な機能

  1. 01Material 3 Expressiveのスプリングモーション
  2. 02AGSLシェーダー効果
  3. 03Canvasとジェネレーティブ描画
  4. 04グラフィックスレイヤー効果とぼかし
  5. 05コーディング前の方針提示

活用シーン

  1. 01重要なUI場面の弾む動きの設計
  2. 02タッチリップルやホログラフィックグラデーションの作成
  3. 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 / 14000.6 / 800
defaultSpatialSpec()位置 / サイズ0.9 / 7000.8 / 380
slowSpatialSpec()位置 / サイズ0.9 / 3000.8 / 200
fastEffectsSpec()不透明度 / 色1.0 / 38001.0 / 3800
defaultEffectsSpec()不透明度 / 色1.0 / 16001.0 / 1600
slowEffectsSpec()不透明度 / 色1.0 / 8001.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 で計算するより低コストです)。

パフォーマンスに関する注意
  • RuntimeShader 1つにつきレンダリングパスが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長方形。必要に応じてグラデーションブラシも使用できます
drawPathStroke または Fill で任意のパスを描画します
drawArc楕円の一部。進捗を表す円弧に便利です
drawLine2つの Offset 間の直線
drawTextTextMeasurer を使ったテキスト描画(推奨)、または 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

参考資料

コメント