mcpField
C
compose-motion

compose-motion

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

compose-motion について

Claude Code、claude.ai、Cowork向けのJetpack ComposeおよびCompose Multiplatform用アニメーションスキルです。一般的なUI要件に合うAPIを示し、連動する遷移、手動制御、ジェスチャー、視差効果を減らす方法を解説します。

主な機能

  1. 01ComposeアニメーションAPIの選択
  2. 02表示とコンテンツのトランジション
  3. 03複数プロパティの連動アニメーション
  4. 04Animatableによる手動制御
  5. 05ジェスチャーと視差効果を減らす指針

活用シーン

  1. 01UI要素の表示・非表示アニメーション
  2. 02複数UIプロパティの変化の連動
  3. 03ドラッグ、スワイプ、スナップ動作の実装
  4. 04アニメーションのアクセシビリティ確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/athevon/genjutsu --skill compose-motion

インストール後、Claude Code で/compose-motionを入力して読み込まれたか確認します。

詳細

バージョン依存です。 以下の API 名、SDK の要件、ブラウザーサポートに関する記述はすべて、 2026-09-08 に一次情報をもとに検証しました。何をいつ照合したかは _jutsu/VERSIONS.md に記載しています。この日付が古い場合は、バージョン番号をもとに作業する前に再検証してください。

Compose Motion - サブスキル

Jetpack Compose のアニメーションの中核です。Android Compose と Compose Multiplatform のプロジェクトで読み込まれます。 ここでは簡潔なルールを説明します。詳しくは references/ を参照してください。 基準:Jetpack Compose 1.12.0(2026年8月時点の最新安定版)、つまり Compose BOM 2026.08.x です。最新の安定版 API のみを使用します。swipeable は使わず、AnimatedContent が適切な場面で animateContentSize を使ったハックも避けます。以下で「Compose 1.x+」と記載されているものは、対象バージョンではなく導入時期を示します。


API の選択ツリー

必要なことAPI
単一の値を時間とともに変化させるanimateFloatAsState、animateDpAsState、animateColorAsState など
表示/マウント・アンマウントAnimatedVisibility(visible) { ... }
状態間のクロスフェードCrossfade(target) { state -> ... }
複数状態の連携updateTransition(target).animateFloat { ... }
手動制御/中断Animatable(initialValue) + animateTo(...)
ループ/無限アニメーションrememberInfiniteTransition().animateFloat(...)
共有要素SharedTransitionLayout + Modifier.sharedElement(...)(Compose 1.7+)
アニメーション付きレイアウト切り替えAnimatedContent(target) { ... }
ドラッグ/スワイプModifier.draggable + Animatable.snapTo/animateTo、またはスナップポイントには Modifier.anchoredDraggable

ルール: 必要な場合にのみ、段階的に高度な API を選んでください。ケースの 70% は animate*AsState で対応できます。中断、連鎖、または速度の読み取りが必要な場合にのみ Animatable を選びます。


Spring API(推奨するデフォルト設定)

用途Spec
UI のスナップ(モーダル、ドロワー、タブ)spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioNoBouncy)
触覚的な動き(ボタンを押して離す、切り替え)spring(stiffness = Spring.StiffnessMedium, dampingRatio = 0.85f)
弾むような表示(トースト、FAB、成功表示)spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy)
ドラッグへの追従(指の動きを 1:1 で追跡)spring(stiffness = Spring.StiffnessHigh, dampingRatio = 1f)

剛性の定数(androidx.compose.animation.core.Spring):StiffnessVeryLow 50f、StiffnessLow 200f、StiffnessMediumLow 400f、StiffnessMedium 1500f(spring() のデフォルト)、StiffnessHigh 10000f です。値が大きいほど、早く静止します。減衰比の定数:DampingRatioHighBouncy 0.2f、DampingRatioMediumBouncy 0.5f、DampingRatioLowBouncy 0.75f、DampingRatioNoBouncy 1.0f(デフォルト)です。1.0 未満ではオーバーシュートします。Spring は durationMillis を無視します。期間を固定する必要がある場合は、代わりに tween(...) を使ってください。


animate*AsState - 基本となる API

val targetAlpha = if (visible) 1f else 0f
val alpha by animateFloatAsState(
    targetValue = targetAlpha,
    animationSpec = spring(stiffness = Spring.StiffnessMedium),
    label = "alpha",
)
Box(modifier = Modifier.alpha(alpha))

label は Layout Inspector/Animation Preview に表示されます。必ず設定してください。未来の自分が今の自分に感謝するでしょう。Dp、Color、Offset、IntOffset、Size、Rect、Float、Int 向けのバリエーションに加えて、TwoWayConverter を使ってカスタム型に対応する汎用の animateValueAsState も用意されています。


AnimatedVisibility - アニメーション付きのマウント/アンマウント

AnimatedVisibility(
    visible = expanded,
    enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
    exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(animationSpec = tween(150)),
) {
    Panel()
}

複数の表示/終了トランジションは + で組み合わせられます。モーションの原則に従い、終了時は表示時より短く、シンプルにしてください(ここでは、完全なスライド+フェードの表示に対して、フェードの終了を 150ms の tween にしています)。コンテンツのコンポーザブルは、表示中またはアニメーション中にのみ実行されます。高コストの子要素を内部に配置しても安全です。


AnimatedContent - 状態に応じたレイアウト切り替え

AnimatedContent(
    targetState = currentTab,
    transitionSpec = {
        (slideInHorizontally { it } + fadeIn()) togetherWith
            (slideOutHorizontally { -it } + fadeOut())
    },
    label = "tabs",
) { tab ->
    TabContent(tab)
}

togetherWith は表示と終了を並行して実行します。using SizeTransform(clip = false) は、コンテンツ間でコンテナーのサイズをどう変化させるかを制御します。キーが重要です。targetState の同一性が変わらない場合、トランジションは実行されません。


Crossfade - 状態間のシンプルなフェード

Crossfade(targetState = isLoading, label = "loadState") { loading ->
    if (loading) Spinner() else Content()
}

フェードだけが必要な場合に使います。スライド、スケール、レイアウト対応など、より複雑な動きが必要な場合は AnimatedContent を選んでください。Crossfade はサイズをアニメーションしません。コンテナーは新しいコンテンツのサイズに即座に切り替わります。

updateTransition - 複数プロパティの連携

val transition = updateTransition(targetState = expanded, label = "expand")
val width by transition.animateDp(label = "width") { if (it) 300.dp else 100.dp }
val color by transition.animateColor(label = "color") { if (it) Color.Blue else Color.Gray }
val corner by transition.animateDp(label = "corner") { if (it) 24.dp else 8.dp }

Box(
    Modifier
        .width(width)
        .background(color, RoundedCornerShape(corner)),
)

同じ状態に応じて複数のプロパティを同時にアニメーションさせる場合に使用します。すべての子要素が同じトランジションタイムラインを共有するため、同期して完了します。各 animate* 呼び出しでは、それぞれのプロパティを調整するための transitionSpec ラムダを指定できます。


Animatable - 手動制御

val offsetX = remember { Animatable(0f) }
LaunchedEffect(triggerEvent) {
    offsetX.animateTo(100f, spring())
    offsetX.animateTo(0f, spring(dampingRatio = Spring.DampingRatioMediumBouncy))
}
Box(Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) })

中断(stop())、連続実行(animateTo は完了時に戻ります)、リアルタイムの速度の読み取り、減衰アニメーションの開始(animateDecay)が必要な場合は、Animatable を使用します。ドラッグ後のフリング、元の位置へのスナップバック、animate*AsState では表現できないあらゆるフローに対応する命令型の手段です。


SharedTransitionLayout(Compose 1.7+) - ヒーローアニメーション

SharedTransitionLayout {
    AnimatedContent(targetState = currentScreen, label = "nav") { screen ->
        when (screen) {
            Screen.List -> ListScreen(
                sharedTransitionScope = this@SharedTransitionLayout,
                animatedVisibilityScope = this@AnimatedContent,
            )
            is Screen.Detail -> DetailScreen(
                item = screen.item,
                sharedTransitionScope = this@SharedTransitionLayout,
                animatedVisibilityScope = this@AnimatedContent,
            )
        }
    }
}

// Inside ListScreen, on the card image:
with(sharedTransitionScope) {
    Image(
        painter = painter,
        contentDescription = null,
        modifier = Modifier.sharedElement(
            sharedContentState = rememberSharedContentState(key = "hero-${item.id}"),
            animatedVisibilityScope = animatedVisibilityScope,
        ),
    )
}

2 つのスコープが下位に渡されます。Modifier.sharedElement 拡張関数が定義されている SharedTransitionScope と、トランジションを駆動する可視性コンテキストである AnimatedVisibilityScope です。アニメーションを実行するには、rememberSharedContentState(key) のキーが画面間で一致している必要があります。詳しくは references/shared-transitions.md をご覧ください。


InfiniteTransition - ループ

val infinite = rememberInfiniteTransition(label = "loader")
val rotation by infinite.animateFloat(
    initialValue = 0f,
    targetValue = 360f,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Restart,
    ),
    label = "rotation",
)
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.rotate(rotation))

RepeatMode.Restart は各サイクルで開始位置に戻り、RepeatMode.Reverse は往復します。アクセシビリティのため、必ず一時停止/無効化の手段を用意してください(下記の「モーションの軽減」セクションを参照)。1 回限りの減衰アニメーション(フリング)には、代わりに Animatable.animateDecay を使用してください。


ジェスチャー(早見表)

必要な操作Modifier
タップModifier.clickable(onClick = ...)
タップ+長押しModifier.combinedClickable(onClick = ..., onLongClick = ...)
ドラッグ(単一軸)Modifier.draggable(state, orientation)
マルチタッチ/カスタムModifier.pointerInput { detectDragGestures { ... } }
スクロール位置の読み取りLazyListState または ScrollState.value
スワイプで閉じる/スナップ位置Modifier.anchoredDraggable(state)(Compose 1.6+、swipeable の後継)
ピンチ+パン+回転Modifier.transformable(state)

詳しくは references/gestures-compose.md(NestedScrollConnection、競合の解決、フリングの減衰)をご覧ください。


アンチパターン(BAD / GOOD)

1. 変形ではなくレイアウトサイズをアニメーションさせる
// BAD - animateDpAsState on width forces a layout pass every frame
val w by animateDpAsState(if (expanded) 300.dp else 100.dp)
Box(Modifier.width(w).height(60.dp))
// GOOD - animate scale via graphicsLayer (composite-only, no layout)
val scale by animateFloatAsState(if (expanded) 3f else 1f, label = "scale")
Box(
    Modifier
        .width(100.dp)
        .height(60.dp)
        .graphicsLayer {
            scaleX = scale
            transformOrigin = TransformOrigin(0f, 0.5f)
        },
)

実際にレイアウトサイズを変更する必要がある場合(親がそのサイズに依存する場合)は、周囲の要素もアニメーションするように Modifier.animateContentSize() を使用するか、切り替え処理を AnimatedContent で囲みます。

2. 入力が隠れた LaunchedEffect(true)/LaunchedEffect(Unit)
// BAD - re-runs only once, BUT also fires on every recomposition surprise
//        when callers swap the composable instance. Worse: anything captured
//        in the lambda is stale.
LaunchedEffect(true) {
    offsetX.animateTo(target)
}
// GOOD - explicit key tied to the trigger
LaunchedEffect(triggerKey) {
    offsetX.animateTo(target)
}

本当に「一度だけ実行」したい場合は、意図的に LaunchedEffect(Unit) を使用し、変化する状態をキャプチャしないようにするか、キャプチャする値を上位に移動します。迷ったときは、読み取る値をキーに指定してください。

3. 項目アニメーションを使う LazyColumn で key を忘れる
// BAD - on insert/delete/reorder, items animate to wrong slots
LazyColumn {
    items(list) { item ->
        AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
    }
}
// GOOD - stable key by id, plus Modifier.animateItem for reorder anim
LazyColumn {
    items(list, key = { it.id }) { item ->
        Row(modifier = Modifier.animateItem()) {
            AnimatedVisibility(visible = item.expanded) { ItemRow(item) }
        }
    }
}

Modifier.animateItem()(Compose 1.7+、animateItemPlacement の後継)は、key が安定していれば、項目の追加/削除/移動を自動的に処理します。

4. スクロール可能なリスト項目内に AnimatedContent をネストする
// BAD - every item runs its own transition graph; scroll = jank
LazyColumn {
    items(list, key = { it.id }) { item ->
        AnimatedContent(targetState = item.state) { state -> Row(state) }
    }
}
// GOOD - lift state, animate only the changing prop on the row
LazyColumn {
    items(list, key = { it.id }) { item ->
        val color by animateColorAsState(if (item.selected) selBg else bg, label = "rowBg")
        Row(Modifier.background(color)) { Content(item) }
    }
}

ルール:負荷の高いアニメーションコンテナー(AnimatedContent、SharedTransitionLayout)は画面のスコープに置き、行ごとには置かないでください。


モーションの軽減 - 尊重する

クロスプラットフォームの基本方針については、../motion-principles/SKILL.md をご覧ください。

@Composable
fun rememberReduceMotion(): Boolean {
    val context = LocalContext.current
    return remember {
        Settings.Global.getFloat(
            context.contentResolver,
            Settings.Global.ANIMATOR_DURATION_SCALE,
            1f,
        ) == 0f
    }
}

val reduce = rememberReduceMotion()
val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    animationSpec = if (reduce) snap() else spring(),
    label = "alpha",
)

ANIMATOR_DURATION_SCALE は、アニメーターの時間スケールを直接読み取ります。より簡潔な呼び出しは ValueAnimator.areAnimatorsEnabled()(API 26+)で、時間スケールが 0 の場合に false を返します。この値は、開発者向けオプションの「アニメーションをオフ」、バッテリーセーバー、ユーザー向けの「アニメーションを削除」(設定 -> ユーザー補助)のいずれかによって設定されます。詳しくは ../mobile-principles/references/accessibility-mobile.md をご覧ください。


クイックリファレンス

必要な情報読み込むファイル
共有トランジションの詳細references/shared-transitions.md
ジェスチャーと nestedScroll のパターンreferences/gestures-compose.md
再コンポジション/カクつき/Layout Inspectorreferences/recomposition-and-anim.md
応用編(M3 Expressive、AGSL、Canvas)../compose-graphics/SKILL.md
CMP/KMP のパターン../compose-multiplatform/SKILL.md
モバイル UX のコンテキスト../mobile-principles/SKILL.md
基礎(タイミング、イージング、アクセシビリティ)../motion-principles/SKILL.md

参考資料

コメント