swiftui-motion について
Claude Code、claude.ai、Cowork向けのSwiftUIアニメーションスキルです。API選択、スプリング、トランジション、ジェスチャーの実装指針に加え、アクセシビリティや性能上の注意点も扱います。
主な機能
- 01SwiftUIアニメーションAPIの選択
- 02スプリングとトランジションの指針
- 03ジェスチャー連動アニメーション
- 04視差効果を減らすアクセシビリティ規則
- 05よくあるアニメーションのアンチパターン
活用シーン
- 01ビューに適したアニメーションAPIの選定
- 02連動する状態遷移の構築
- 03ジェスチャー操作の追加
- 04視差効果を減らす設定の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ athevon/ genjutsu --skill swiftui-motionインストール後、Claude Code で/swiftui-motionを入力して読み込まれたか確認します。
詳細
バージョン依存の情報です。 以下の API 名、SDK の要件、ブラウザーサポートに関する記述はすべて、一次情報に基づき 2026-09-08 に確認済みです。何を、いつ確認したかは
_jutsu/VERSIONS.mdに記載されています。この日付が古い場合は、バージョン番号に基づいて作業する前に再確認してください。
SwiftUI モーション
SwiftUI アニメーションのコアです。SwiftUI プロジェクト(iOS、macOS、Apple のマルチターゲット)で読み込まれます。 ここでは簡潔なルールを紹介します。詳細は
references/を参照してください。../motion-principles/SKILL.md(基礎)および../mobile-principles/SKILL.md(タッチ UX)と併用してください。
アニメーション API の選択ツリー
| 要件 | API |
|---|---|
| 時間経過に伴う単一の値 | withAnimation { } + @State または .animation(_, value:) |
| 複数の状態を連携させる | PhaseAnimator(phases)(iOS 17 以降) |
| 時間ベースのキーフレーム | KeyframeAnimator(initialValue:repeating:content:)(iOS 17 以降) |
| カスタムプロパティアニメーション | @Animatable マクロ(コンパイルには Xcode 26 / SwiftUI 2025 SDK が必要です。ランタイムでの利用可能範囲は iOS 13.0 以降 / macOS 10.15 以降 / watchOS 6.0 以降)、または手動で Animatable プロトコルを実装(iOS 13 以降) |
| 共有要素のトランジション | matchedGeometryEffect(id:in:) |
| ジェスチャー駆動 | DragGesture / MagnifyGesture + .offset / .scaleEffect |
| 無限ループ | .animation(.linear.repeatForever(autoreverses: true), value: ...) または .phaseAnimator |
ルール: まずは withAnimation を使ってください。順序のある状態が 3 つ以上ある場合に限り、PhaseAnimator を使います。並列の時間ベースのトラックが必要な場合に限り、KeyframeAnimator を使います。
スプリング(気にするべきイージングはこれだけです)
SwiftUI には、名前付きのスプリングプリセット(iOS 17 以降)として .snappy、.bouncy、.smooth のほか、パラメーターを指定する .spring(duration:bounce:blendDuration:) と .interactiveSpring(...) が用意されています。名前付きの 3 種類はいずれも (duration:extraBounce:) のオーバーロードも利用できます。これらを使ってください。プリセットが適切でない場合に限り、response / dampingFraction を調整します。
| プリセット(iOS 17 以降) | 同等の設定 | 印象 |
|---|---|---|
.snappy | .spring(response: 0.5, dampingFraction: 0.85) | キビキビした UI |
.bouncy | .spring(response: 0.5, dampingFraction: 0.7) | 遊び心のある動き |
.smooth | .spring(response: 0.5, dampingFraction: 1.0) | 落ち着いた、跳ねない動き |
.interactiveSpring() | .spring(response: 0.15, dampingFraction: 0.86) | ジェスチャーに追従 |
response はスプリングの周期で、剛性を時間で表したものです。つまり、1 回の振動にかかる時間です。収束時間ではありません。たとえば .spring(response: 0.5, dampingFraction: 1.0) は、0.5 秒を大きく超えても動き続けます。値が小さいほどキビキビし、大きいほど柔らかくなります。dampingFraction は 0...1 の範囲でオーバーシュートの強さを表します(1 = オーバーシュートなし、0 = 振動し続けます。0 は決して使わないでください)。UI の作業では、response: 0.2...0.5、dampingFraction: 0.7...1.0 の範囲にしてください。詳しくは references/springs-cheatsheet.md を参照してください。
iOS 17 以降では、bounce が 0...1 の .spring(duration:bounce:) も利用できます(0 = 臨界減衰、1 = 最大限に跳ねる)。これは同じスプリングを、デザイナーにも分かりやすい形で公開したものです。
.animation(.spring(duration: 0.4, bounce: 0.3), value: state)
暗黙的アニメーションと明示的アニメーション
// Implicit - via .animation modifier (binds to a value)
Circle()
.scaleEffect(scale)
.animation(.spring(.snappy), value: scale)
// Explicit - via withAnimation block
Button("Grow") {
withAnimation(.smooth) { scale = 1.5 }
}
ルール: ユーザー操作によって引き起こされる状態変更には、明示的なアニメーション(withAnimation)を優先してください。値の変化を常にアニメーションさせる場合(例:どこから更新されてもアニメーションするプログレスバー)には、暗黙的なアニメーションを使います。同じプロパティに両方を使わないでください。外側の withAnimation が優先されますが、暗黙的な .animation 修飾子も実行され、分かりにくい形で重なります。
トランジション
トランジションは、if、switch、ForEach 内のビューの挿入・削除を制御します。親のアニメーションコンテキストが発生したときに実行されるため、状態変更を withAnimation で囲んでください。
if visible {
Card().transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .opacity.animation(.easeIn(duration: 0.15))
))
}
悪い例 — ブラックホールに消えてしまいます:
Card().transition(.scale) // scales to 0, the universal "broken" feel
良い例 — スケールを 0 にしないでください:
Card().transition(
.scale(scale: 0.95).combined(with: .opacity)
)
iOS 17 以降では、Transition プロトコルを使ってカスタムトランジションを作成できる .transition(_:) 修飾子もあります。多くのビューでタイミングを共有する場合に便利です。作業の 90% では、組み込みのコンビネーター(.move、.opacity、.scale、.slide、.push、.asymmetric、.combined(with:))で十分です。
matchedGeometryEffect(ヒーローアニメーション)
同じNamespace内の2つのビューに、同じidを指定します。元のビューが置き換わる際、SwiftUIはフレームと位置を補間します。
struct Gallery: View {
@Namespace private var ns
@State private var expanded = false
var body: some View {
ZStack {
if expanded {
LargeCard()
.matchedGeometryEffect(id: "card", in: ns)
.onTapGesture { withAnimation(.spring(.smooth)) { expanded = false } }
} else {
SmallCard()
.matchedGeometryEffect(id: "card", in: ns)
.onTapGesture { withAnimation(.spring(.smooth)) { expanded = true } }
}
}
}
}
isSource: true(基準となるビューではデフォルト)を指定すると、SwiftUIはどのフレームから補間するかを判断します。よくある注意点は、無関係な名前空間間でのIDの衝突、ビューIDの不安定さ(配列のインデックスではなく安定したIDを使います)、および遷移先のビューがまだ存在しないifブランチからアニメーションで退出することです(両方のブランチを同じ親ビューで囲み、ビューを削除するのではなく、不透明度を使って非表示にします)。
PhaseAnimator(iOS 17以降)
順序のある状態遷移に使います。このAPIは、Equatable(Hashableではありません)に制約されたフェーズ型のsome Sequenceを受け取ります。CaseIterableは、Phase.allCasesを渡せるようにするための便宜的な機能にすぎません。SwiftUIはシーケンスを順にたどり、最後の要素で落ち着きます。
enum SuccessPhase: CaseIterable { case start, scaleUp, rotate, settle }
struct SuccessCheck: View {
@State private var trigger = false
var body: some View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
.phaseAnimator(SuccessPhase.allCases, trigger: trigger) { view, phase in
view
.scaleEffect(phase == .start ? 0 : phase == .settle ? 1 : 1.2)
.rotationEffect(.degrees(phase == .rotate ? 360 : 0))
.opacity(phase == .start ? 0 : 1)
} animation: { phase in
switch phase {
case .start: .smooth(duration: 0.05)
case .scaleUp: .spring(.bouncy, blendDuration: 0.25)
case .rotate: .spring(response: 0.4, dampingFraction: 0.8)
case .settle: .smooth(duration: 0.2)
}
}
.onTapGesture { trigger.toggle() }
}
}
trigger:は省略可能です。省略すると、表示時にフェーズが自動的に一度進みます。ボタンのタップやモデルの更新など、外部からのシグナルが必要な場合に使用します。フェーズは順番に実行され、並列には実行されません。並列処理が必要な場合は、KeyframeAnimatorを使います。
KeyframeAnimator(iOS 17以降)
並列トラックを使った、時間ベースの連続アニメーションに使います。各KeyframeTrackは1つのキーパスを個別にアニメーション化し、SwiftUIはそれらすべてを同時に実行します。
struct AnimationValues {
var scale: Double = 1
var rotation: Angle = .zero
var opacity: Double = 1
}
struct HeartTap: View {
@State private var counter = 0
var body: some View {
Image(systemName: "heart.fill")
.font(.system(size: 64))
.foregroundStyle(.pink)
.keyframeAnimator(initialValue: AnimationValues(), trigger: counter) { content, values in
content
.scaleEffect(values.scale)
.rotationEffect(values.rotation)
.opacity(values.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.3, duration: 0.15)
SpringKeyframe(1.0, duration: 0.3, spring: .bouncy)
}
KeyframeTrack(\.rotation) {
CubicKeyframe(.degrees(15), duration: 0.1)
CubicKeyframe(.degrees(-15), duration: 0.2)
CubicKeyframe(.degrees(0), duration: 0.15)
}
}
.onTapGesture { counter += 1 }
}
}
キーフレーム型は4種類あります。LinearKeyframe(点間を一定速度で移動)、SpringKeyframe(スプリングで収束)、CubicKeyframe(三次ベジェイージング)、MoveKeyframe(補間せず、瞬時に切り替え)。値の変化をトリガーとしてアニメーションを再実行します。詳しくは、references/phase-keyframe-deep.mdを参照してください。
Animatable / @Animatable
補間が必要なカスタム描画に使います。@Animatableマクロ(WWDC25でiOS 26 SDKとともに導入されたため、Xcode 26以降が必要です。宣言されている対応バージョンはiOS 13.0以降/macOS 10.15以降/watchOS 6.0以降のため、旧バージョンにもデプロイできます)は、構造体/クラス/列挙型に付けると、アニメーション可能な格納プロパティ(Double、CGFloat、Angle、CGSize、CGPoint、UnitPoint、および任意のVectorArithmetic)から、Animatableへの適合とanimatableDataの両方を合成します。格納プロパティに@AnimatableIgnored(同じ対応バージョン)を付けると、合成されるデータから除外できます。古いツールチェーンでは、引き続きanimatableDataを手動で記述できます。
struct ProgressRing: Shape {
var progress: Double // 0...1
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.addArc(
center: CGPoint(x: rect.midX, y: rect.midY),
radius: rect.width / 2,
startAngle: .degrees(-90),
endAngle: .degrees(-90 + 360 * progress),
clockwise: false
)
return p
}
}
ProgressRing(progress: progress)
.stroke(.tint, lineWidth: 6)
.animation(.spring(.smooth), value: progress)
複数のプロパティを持つシェイプでは、AnimatablePair<A, B>(またはネストしたペア)をanimatableDataとして使います。@Animatableマクロを使えば、この定型コードは不要です。シェイプに注釈を付けると、VectorArithmeticに適合する各格納プロパティ(Double、CGFloat、Angle、CGSize、CGPoint、UnitPointなど)が、自動的に合成されるanimatableDataにまとめられます。プロパティを除外するには、@AnimatableIgnoredを指定します。
ジェスチャ
| ジェスチャ | 型 | 用途 |
|---|---|---|
TapGesture | 離散 | タップ、ダブルタップ(count: 2) |
LongPressGesture | 離散+連続(onEnded/onChanged) | コンテキストメニュー、長押し録音 |
DragGesture | 連続 | ドラッグ、スワイプして閉じる |
MagnifyGesture | 連続 | ピンチしてズーム(iOS 17以降、MagnificationGestureの後継) |
RotateGesture | 連続 | 回転(iOS 17以降、RotationGestureの後継) |
SpatialTapGesture(iOS 16以降、macOS 13以降) | 離散 | 位置情報を含むタップ |
let tap = TapGesture().onEnded { print("tap") }
let drag = DragGesture().onChanged { value in offset = value.translation }
ZStack { ... }
.gesture(tap.simultaneously(with: drag))
合成演算子は3つあります。.simultaneously(with:)(並列に認識)、.sequenced(before:)(一方が完了してからもう一方を実行)、.exclusively(before:)(どちらか一方のみで、両方ではない)。子ビューがジェスチャを認識できるタイミングを細かく制御するには、.gesture、.subviews、.all、.noneを指定して.simultaneousGesture(_, including: GestureMask)を使います。詳しくは、references/gestures-swiftui.mdを参照してください。
アンチパターン(BAD/GOOD)
1. 非推奨の.animation形式(値のバインディングなし)
// BAD - implicit-anim-everywhere, deprecated in iOS 15+
Circle().scaleEffect(scale).animation(.easeInOut)
// GOOD - bind to a specific value
Circle().scaleEffect(scale).animation(.easeInOut, value: scale)
// OR
withAnimation(.easeInOut) { scale = 1.5 }
2. フレームサイズを直接アニメーション化する
// BAD - .frame() drives layout pass every frame, drops fps under load
Card().frame(height: expanded ? 400 : 100)
.animation(.spring(), value: expanded)
// GOOD - animate transform-equivalents (scale, offset) that the compositor handles
Card()
.frame(height: 400)
.scaleEffect(expanded ? 1 : 0.4, anchor: .top)
.animation(.spring(), value: expanded)
// OR for actual layout transitions, use matchedGeometryEffect
3. 0にスケールする(無に消える罠)
// BAD - element vanishes into a black hole, feels broken
Card().transition(.scale)
// GOOD - minimum scale 0.9-0.95 + opacity
Card().transition(.scale(scale: 0.95).combined(with: .opacity))
4. body内のwithAnimation
// BAD - body runs on every render, animation re-fires arbitrarily
var body: some View {
let _ = withAnimation(.spring()) { scale = 1.2 } // never do this
Circle().scaleEffect(scale)
}
// GOOD - trigger from user actions or .onChange
var body: some View {
Circle()
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring()) { scale = scale == 1 ? 1.2 : 1 }
}
}
動きの軽減への対応
必須です。SwiftUI では、iOS / macOS のアクセシビリティ設定「視差効果を減らす」を環境経由で利用できます。プラットフォーム横断の理由については、../motion-principles/SKILL.md を参照してください。
struct Hero: View {
@Environment(\.accessibilityReduceMotion) var reduceMotion
@State private var shown = false
var body: some View {
Text("Welcome")
.opacity(shown ? 1 : 0)
.offset(y: shown ? 0 : (reduceMotion ? 0 : 20))
.animation(reduceMotion ? .none : .spring(.smooth), value: shown)
.onAppear { shown = true }
}
}
ルール:動きの軽減が有効な場合でも、クロスフェードと不透明度の変化は許可されます。大きな移動、ゼロからの拡大、視差効果、ループする動きは無効にする必要があります。
クイックリファレンス:サブスキルの読み込み
| 必要な内容 | 読み込むファイル |
|---|---|
| スプリングの詳細(response/dampingFraction の調整、雰囲気) | references/springs-cheatsheet.md |
| PhaseAnimator と KeyframeAnimator を使った複雑なシーケンス | references/phase-keyframe-deep.md |
| ジェスチャーの合成と競合の解決 | references/gestures-swiftui.md |
| 高度なビジュアル(Metal、Liquid Glass) | ../swiftui-graphics/SKILL.md |
| プラットフォーム横断の UX(モバイル、デスクトップ) | ../mobile-principles/SKILL.md、../desktop-principles/SKILL.md |
| 基礎(タイミング、イージング、アクセシビリティ) | ../motion-principles/SKILL.md |