swiftui-animations について
開発者向けの SwiftUI アニメーションスキルです。状態に応じた動き、トランジション、ジェスチャー、最新 API、アクセシビリティを解説します。
主な機能
- 01状態駆動のアニメーション指針
- 02暗黙・明示アニメーションのパターン
- 03ビュー遷移とジェスチャー効果
- 04PhaseAnimator と KeyframeAnimator
- 05視差効果を減らす設定への対応
活用シーン
- 01SwiftUI のアニメーション設計
- 02独自のアニメーション効果の構築
- 03ジェスチャーに応じた動きの作成
- 04視差効果を減らす設定への対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ tryhuset/ agent-skills --skill swiftui-animationsインストール後、Claude Code で/swiftui-animationsを入力して読み込まれたか確認します。
詳細
SwiftUI アニメーションのエキスパートです。Apple のベストプラクティスに従って、滑らかでパフォーマンスに優れ、アクセシビリティに配慮したアニメーションを作成できるよう、開発者を支援します。
基本原則
SwiftUI アニメーションの仕組み
- アニメーションは状態の変化によって駆動されます – SwiftUI は古い状態と新しい状態の 差分 をアニメーション化します
- トランザクションは下位へ伝播します –
withAnimationで状態の変化を囲むと、トランザクションがビュー階層を通って流れます - アニメーション化されるのはアニメーション可能なデータだけです – 型は
Animatableに準拠している必要があります(ほとんどの組み込み型が準拠しています。CGFloat、Color、CGSize など) - アニメーションは宣言的です – 最終状態を記述すると、補間は SwiftUI が行います
メンタルモデル
State Change → Transaction Created → View Diffed → Animatable Properties Interpolated → Frames Rendered
アニメーションは、ビューの2つのスナップショット間の自動補間 と考えてください。必要なのは次のとおりです。
- 2つの状態を明確に定義する
- 使用するタイミングカーブを SwiftUI に指定する
- アニメーション化するプロパティが
Animatableであることを確認する
暗黙的アニメーション
最も簡単なアニメーション方法です。.animation() モディファイアを追加するか、状態の変化を withAnimation で囲みます。
withAnimation(推奨)
状態の変化を囲み、その結果生じるすべてのビュー更新をアニメーション化します。
withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
isExpanded.toggle()
}
使用する場面: 状態の変化を制御できる場合(ボタンタップ、ジェスチャー、レスポンスなど)。
.animation() モディファイア
監視対象の値が変化するたびにアニメーション化します。
Circle()
.scaleEffect(isActive ? 1.2 : 1.0)
.animation(.easeInOut(duration: 0.3), value: isActive)
使用する場面: 状態の変化を制御できない場合(バインディング、親の状態など)。
重要: 必ず value: パラメーターを使用してください。値を指定しない従来の .animation(.default) は非推奨で、予測不能な動作を引き起こします。
タイミングカーブ
| カーブ | 用途 |
|---|---|
.linear | 機械的な一定速度の動き(プログレスバー) |
.easeIn | ユーザーの操作によって入場するオブジェクト |
.easeOut | 所定の位置に収まるオブジェクト |
.easeInOut | 一般的な UI、対称的な動き |
.spring(duration:bounce:) | iOS のデフォルトの選択肢 – 自然な感触 |
.spring(response:dampingFraction:) | 細かく調整できるスプリング物理 |
.interactiveSpring | ジェスチャーに反応する、きびきびとした動き |
目安: 特別な理由がない限り、.spring() を使用してください。Apple の HIG では、ほとんどのインタラクションにスプリングアニメーションを推奨しています。
明示的アニメーション
暗黙的アニメーションでは不十分な、カスタム型や複雑なアニメーションに使用します。
Animatable プロトコル
animatableData プロパティを提供すれば、どの型でもアニメーション化できます。
struct AnimatableGradient: View, Animatable {
var progress: CGFloat
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
var body: some View {
// Use progress (0→1) to interpolate colors, positions, etc.
}
}
SwiftUI は古い状態と新しい状態の間の補間値を使って、セッターを繰り返し呼び出します。
AnimatableModifier
再利用可能なアニメーション効果に使用します。
struct CountingModifier: AnimatableModifier {
var value: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
func body(content: Content) -> some View {
content.overlay(Text("\(Int(value))"))
}
}
// Usage: Text counts from 0 to 100
Text("Score").modifier(CountingModifier(value: score))
Shape のアニメーション
Shape は animatableData を使ってアニメーション化されます。複数の値には AnimatablePair を使用します。
struct Wedge: Shape {
var startAngle: Double
var endAngle: Double
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(startAngle, endAngle) }
set {
startAngle = newValue.first
endAngle = newValue.second
}
}
func path(in rect: CGRect) -> Path { /* ... */ }
}
3つ以上の値には ネストしたペア を使用します:AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>>
トランジション
トランジションは、ビュー階層へのビューの追加と削除をアニメーション化します。ビューが追加または削除されたとき(if、switch、ForEach の変更による場合)にのみ適用されます。
組み込みのトランジション
if showDetail {
DetailView()
.transition(.slide)
}
| トランジション | 効果 |
|---|---|
.opacity | フェードイン/アウト |
.slide | 先頭の端からスライド |
.move(edge:) | 指定した端からスライド |
.scale | 中心から拡大 |
.scale(anchor:) | アンカーポイントから拡大 |
.push(from:) | 新しいビューが押し込み、古いビューが押し出される |
.offset(x:y:) | オフセット位置からアニメーション |
トランジションの組み合わせ
.transition(.scale.combined(with: .opacity))
// Or use extension for reusability:
extension AnyTransition {
static var scaleAndFade: AnyTransition {
.scale(scale: 0.8).combined(with: .opacity)
}
}
非対称トランジション
追加時と削除時で異なるアニメーションを指定します。
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
カスタムトランジション
任意の ViewModifier から作成できます。
struct SlideAndBlur: ViewModifier {
let active: Bool
func body(content: Content) -> some View {
content
.offset(x: active ? 200 : 0)
.blur(radius: active ? 10 : 0)
}
}
extension AnyTransition {
static var slideBlur: AnyTransition {
.modifier(
active: SlideAndBlur(active: true),
identity: SlideAndBlur(active: false)
)
}
}
重要な注意点: トランジションを機能させるには、ビューを追加または削除する状態の変化を withAnimation で囲む必要があります。ビュー自体の .animation() モディファイアではトランジションは開始されません。
ジェスチャー駆動アニメーション
ユーザーのタッチにリアルタイムで反応するインタラクティブなアニメーションです。
スプリングで戻る基本的なドラッグ
@State private var offset: CGSize = .zero
var body: some View {
Circle()
.offset(offset)
.gesture(
DragGesture()
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
offset = .zero
}
}
)
}
自動リセットのための GestureState
@GestureState はジェスチャーの終了時に自動的にリセットされるため、一時的な状態に最適です。
@GestureState private var dragOffset: CGSize = .zero
var body: some View {
Card()
.offset(dragOffset)
.animation(.interactiveSpring, value: dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
)
}
速度を考慮したアニメーション
ジェスチャーの速度を利用して、自然な動きで離すようにします。
.onEnded { gesture in
let velocity = CGVector(
dx: gesture.velocity.width / 300,
dy: gesture.velocity.height / 300
)
withAnimation(.spring(response: 0.4, dampingFraction: 0.7, blendDuration: 0.25).speed(1)) {
// Factor velocity into final position
}
}
アニメーションの進行状況を追跡する
GeometryReader + PreferenceKey、または iOS 17 の onGeometryChange を使って、連携するエフェクトのアニメーション値を読み取ります。
ジェスチャー向けのスプリングパラメーター
| コンテキスト | 推奨するスプリング |
|---|---|
| ドラッグ中(リアルタイム) | .interactiveSpring またはアニメーションなし |
| 元の位置に戻す | .spring(response: 0.4, dampingFraction: 0.7) |
| ターゲットに移動させる | .spring(response: 0.5, dampingFraction: 0.8) |
| 位置にスナップさせる | .spring(response: 0.3, dampingFraction: 0.9) |
最新の API(iOS 17 以降)
iOS 17 では、複雑なシーケンスを簡潔に記述できる強力な宣言型アニメーション API が導入されました。
PhaseAnimator
離散したフェーズを自動的に繰り返します。ループや複数ステップのアニメーションに最適です。
enum BouncePhase: CaseIterable {
case initial, compress, stretch, settle
var scale: CGSize {
switch self {
case .initial: CGSize(width: 1, height: 1)
case .compress: CGSize(width: 1.1, height: 0.9)
case .stretch: CGSize(width: 0.9, height: 1.1)
case .settle: CGSize(width: 1, height: 1)
}
}
}
PhaseAnimator(BouncePhase.allCases) { phase in
Circle()
.scaleEffect(phase.scale)
} animation: { phase in
switch phase {
case .initial: .spring(duration: 0.2, bounce: 0.5)
default: .spring(duration: 0.25, bounce: 0.3)
}
}
トリガーベース: trigger: パラメーターを追加すると、ループではなく値の変化に応じて実行できます。
PhaseAnimator(phases, trigger: triggerValue) { phase in ... }
KeyframeAnimator
複数のプロパティにキーフレームを設定して、細かく制御できます。
KeyframeAnimator(initialValue: AnimationState()) { state in
Circle()
.offset(x: state.xOffset)
.scaleEffect(state.scale)
.opacity(state.opacity)
} keyframes: { _ in
KeyframeTrack(\.xOffset) {
LinearKeyframe(0, duration: 0.1)
SpringKeyframe(100, duration: 0.4, spring: .bouncy)
SpringKeyframe(0, duration: 0.3)
}
KeyframeTrack(\.scale) {
LinearKeyframe(1.0, duration: 0.1)
CubicKeyframe(1.3, duration: 0.2)
CubicKeyframe(1.0, duration: 0.4)
}
}
キーフレームの種類:
LinearKeyframe– 速度一定CubicKeyframe– ベジェイージングSpringKeyframe– 物理ベースMoveKeyframe– 即時移動(補間なし)
新しいスプリング構文
iOS 17 では、スプリングのパラメーターが簡潔になりました。
// Old (still works)
.spring(response: 0.5, dampingFraction: 0.7)
// New – more intuitive
.spring(duration: 0.5, bounce: 0.3) // bounce: 0 = no bounce, 1 = max bounce
// Presets
.spring(.smooth) // No bounce
.spring(.snappy) // Slight bounce
.spring(.bouncy) // Pronounced bounce
API の使い分け
| シナリオ | API |
|---|---|
| 単一の状態変化 | withAnimation |
| ループ/繰り返しアニメーション | PhaseAnimator |
| 複雑な複数プロパティの演出 | KeyframeAnimator |
| ユーザーがトリガーするシーケンス | trigger: を指定した PhaseAnimator |
| タイミングの細かな制御 | KeyframeAnimator |
重要なルール
必ず行うこと:
.animation()修飾子では常にvalue:パラメーターを使います。値を指定しない形式は非推奨で、バグがあります。.animation()よりwithAnimationを優先します。アニメーションを開始するトリガーをより明示的に制御できます。- デフォルトではスプリングを使います。直線やイーズ曲線より自然に感じられます。
- モーションの低減に配慮します。
accessibilityReduceMotionを確認してください(「アクセシビリティ」セクションを参照)。 - フレームではなくレイアウトをアニメーションさせます。
frameを直接変更するのではなく、.offset()、.scaleEffect()、.opacity()を使います。 - UI の応答は 400ms 未満にします。それ以上では動きが遅く感じられます。
- 実機でテストします。シミュレーターと実機ではタイミングが異なります。
行わないこと:
bodyの計算中にアニメーションさせないでください。bodyは純粋であるべきです。状態の変化を起点にアニメーションを開始します。value:を指定せずに.animation()を使わないでください。予測不能な連鎖アニメーションが発生します。- フレームワークの動作に逆らわないでください。アニメーションの実現が難しい場合は、アプローチを見直します。
- 同時に多くのプロパティをアニメーションさせないでください。明瞭さを保つため、最大 2~3 個にします。
- シーケンス処理に
DispatchQueue.main.asyncAfterを使わないでください。PhaseAnimatorまたは完了ベースの API を使います。 withAnimationブロックをネストしないでください。内側のブロックが優先され、外側は無視されます。- アニメーションの完了を前提にしないでください。SwiftUI は完了コールバックを保証しません。重要なシーケンス処理には
Transactionを使います。
よくある注意点
| 問題 | 原因 | 対処方法 |
|---|---|---|
| トランジションがアニメーションしない | 状態変更が withAnimation で囲まれていない | if 条件の変更を withAnimation で囲みます |
| アニメーションが 2 回実行される | withAnimation と .animation() の両方を使用している | どちらか一方を選びます |
| アニメーションがぎこちない | アニメーションに対応していないプロパティをアニメーションさせている | 型が Animatable に準拠しているか確認します |
| スプリングがいつまでも収束しない | dampingFraction が低すぎる | 収束させるには 0.7 以上を使うか、.speed() を追加します |
| 間違ったビューがアニメーションする | トランザクションが予期せず伝播している | .transaction { $0.animation = nil } を使ってブロックします |
| リスト項目のアニメーションがおかしい | 安定した id がない | Identifiable に準拠し、安定した ID を設定します |
アクセシビリティ
モーションの低減への配慮
ユーザーはシステム設定で「視差効果を減らす」を有効にできます。必ず代替手段を用意してください。
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Card()
.transition(reduceMotion ? .opacity : .slide.combined(with: .opacity))
}
// For animations:
func animateChange() {
if reduceMotion {
// Instant or simple fade
withAnimation(.easeOut(duration: 0.15)) {
isExpanded.toggle()
}
} else {
// Full spring animation
withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
便利なヘルパー
extension Animation {
static func respectful(_ animation: Animation, reducedMotion: Animation = .easeOut(duration: 0.15)) -> Animation {
// Use at call site with @Environment check
animation
}
}
// Or create a View extension:
extension View {
func animateRespectfully<V: Equatable>(
_ animation: Animation,
value: V,
reduceMotion: Bool
) -> some View {
self.animation(reduceMotion ? .easeOut(duration: 0.15) : animation, value: value)
}
}
ガイドライン
- フェードは常に安全です –
.opacityのトランジションは誰にとっても利用できます - なくすのではなく、減らしましょう – 動きは理解の助けになります。控えめにするだけで十分です
- 前庭系への刺激を避けましょう – 大きなズーム、視差、回転は問題になることがあります
- 設定を有効にしてテストしましょう – 「設定」→「アクセシビリティ」→「モーション」→「視差効果を減らす」