Contents
SwiftUI アニメーションの基本概念(暗黙的 vs 明示的)
SwiftUI では、状態が変化したときに自動でアニメーションを付与できる「暗黙的」な手法と、コード上で開始タイミングやイージングを明示的に指定する「明示的」な手法があります。
画面遷移のようにシンプルな変化は暗黙的で十分ですが、ドラッグ操作や複数ビュー間で同期させる必要がある場合は明示的アニメーションを選択します。このセクションでは両者の特徴と実装例を比較し、どちらをいつ使うべきかの指針を示します。
暗黙的アニメーション
暗黙的アニメーションは animation(_:value:) 修飾子でビューにイージングを付与すると、以降の状態変化すべてに同じアニメーションが自動適用されます。コード量が最小になるため、簡易的な UI 変更に向いています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
struct ImplicitDemo: View { @State private var expanded = false var body: some View { VStack(spacing: 24) { Rectangle() .fill(.blue) .frame(width: 100, height: expanded ? 200 : 100) // 状態が変わるたびに同じイージングが適用される .animation(.easeInOut(duration: 0.4), value: expanded) Button("Toggle") { expanded.toggle() } } } } |
- メリット:記述がシンプルで、状態変化が少ない画面に最適。
- デメリット:個別のトリガーや途中で止めることはできず、全体に同一イージングが強制される。
明示的アニメーション
明示的アニメーションは withAnimation クロージャ内で状態変更を行うことで、開始タイミング・イージング・遅延などを細かく制御できます。複数ビューに異なるアニメーションを同時適用したい場合や、非同期処理と組み合わせるケースで有効です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
struct ExplicitDemo: View { @State private var offset = CGFloat.zero var body: some View { VStack(spacing: 24) { Circle() .fill(.red) .frame(width: 80, height: 80) .offset(x: offset) Button("Slide") { // アニメーションのパラメータを明示的に指定 withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) { offset = offset == .zero ? 150 : .zero } } } } } |
- メリット:開始・終了タイミングを自由に制御でき、複数アニメーションの組み合わせが容易。
- デメリット:コード量が増えるため、乱用すると可読性が低下しやすい。
ポイント:単純な状態遷移は暗黙的で済ませ、インタラクティブ操作や複数要素の同期が必要なときは
withAnimationなど明示的手法を選びましょう。
アニメーション実装の主要 API と使い分け
SwiftUI のアニメーションは大きく分けて withAnimation、.animation 修飾子、そして遷移系 API(transition・matchedGeometryEffect)があります。この章ではそれぞれの役割と典型的な組み合わせパターンを示します。
withAnimation の活用シーン
withAnimation はクロージャ内部で起きた状態変化だけにアニメーションを付与するため、条件分岐や非同期処理と相性が良いです。以下はデータ取得完了後に詳細ビューを表示する例です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
struct WithAnimationExample: View { @State private var showDetail = false var body: some View { VStack(spacing: 24) { if showDetail { DetailView() .transition(.move(edge: .bottom) .combined(with: .opacity)) } Button("Toggle Detail") { Task { await fetchData() // データ取得後にだけアニメーションさせる withAnimation(.easeInOut) { showDetail.toggle() } } } } } private func fetchData() async { try? await Task.sleep(nanoseconds: 300_000_000) // 擬似遅延 } } |
- ポイント:非同期タスクや条件分岐と組み合わせることで、ユーザー体験を自然に保てます。
.animation 修飾子の適用シーン
.animation はビュー全体にデフォルトイージングを付与し、状態が変わったたびに自動でアニメーションします。リストの並び替えや大量削除といった「一括」変更に便利です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
struct ListAnimationDemo: View { @State private var items = Array(0..<5) var body: some View { List { ForEach(items, id: \.self) { item in Text("Item \(item)") .swipeActions { Button(role: .destructive) { // 個別削除は明示的に制御 withAnimation { items.removeAll { $0 == item } } } label: { Label("Delete", systemImage: "trash") } } } } // リスト全体の再配置やリロード時にデフォルトアニメーションを適用 .animation(.default, value: items) } } |
- ポイント:全体的な変化は
.animation、細かい操作はwithAnimationと組み合わせると制御しやすくなります。
transition と matchedGeometryEffect の実践例
画面遷移や要素間の位置ずれを滑らかにする代表的テクニックです。以下はカードタップで詳細ビューへ拡大し、戻るときにフェードアウトさせるサンプルです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
struct MatchedGeometryDemo: View { @Namespace private var ns @State private var selected: Int? = nil var body: some View { ZStack { if let id = selected { DetailCard(id: id, namespace: ns) { selected = nil } .transition(.opacity) } else { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) { ForEach(0..<8) { i in CardView(id: i, namespace: ns) .onTapGesture { withAnimation(.spring()) { selected = i } } } } } } } } } struct CardView: View { let id: Int var namespace: Namespace.ID var body: some View { RoundedRectangle(cornerRadius: 12) .fill(Color.orange) .matchedGeometryEffect(id: "card\(id)", in: namespace) .frame(height: 120) } } struct DetailCard: View { let id: Int var namespace: Namespace.ID var closeAction: () -> Void var body: some View { VStack(spacing: 24) { RoundedRectangle(cornerRadius: 0) .fill(Color.orange) .matchedGeometryEffect(id: "card\(id)", in: namespace) .frame(height: 300) Button("Close", action: closeAction) } .ignoresSafeArea() } } |
- ポイント:
matchedGeometryEffectが同一idのビュー間で位置・サイズを自動補完し、.transitionと併用すると表示/非表示のフェードも簡単に実装できます。
カスタムアニメーション曲線と高度なテクニック
標準イージングだけでは満足できない UI が増えてきたため、独自のベジェ曲線や 形状変形 を行う手法を解説します。以下の実装は現在の SwiftUI(iOS 17 以降)で動作することが確認されています。
カスタムベジェ曲線の定義
SwiftUI には TimingCurveProvider というプロトコルは存在しません。その代わり、Animation.timingCurve(_:_:_:duration:) を使って 4‑ポイントベジェ曲線を直接指定できます。下記は「バウンド感」を強調したカーブの例です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
extension Animation { /// バウンス感を持つカスタムイージング(制御点は経験的に決定) static var bounce: Animation { .timingCurve(0.5, 1.8, // P1 (x, y) 0.3, 1.0, // P2 (x, y) duration: 0.6) } } struct BounceDemo: View { @State private var offset = CGFloat.zero var body: some View { Circle() .fill(Color.purple) .frame(width: 80, height: 80) .offset(y: offset) .onTapGesture { withAnimation(.bounce) { offset = offset == .zero ? -200 : .zero } } } } |
- ポイント:
timingCurveは制御点(x1, y1, x2, y2)とdurationを受け取り、任意のベジェ曲線を生成します。Animation.interpolatingSpringとは異なり、時間軸が固定されたイージングです。
AnimatableModifier による高度な形状変化
シェイプ同士の連続的な補間は Animatable プロトコルだけでなく、AnimatableModifier を組み合わせると柔軟に実装できます。ここでは 円 → 星 の変形を例示します(本格的な点列取得は省略し、概念だけを示します)。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
struct MorphShape: Shape { /// 0.0 が円、1.0 が星 var progress: CGFloat // SwiftUI に補間させるデータ var animatableData: CGFloat { get { progress } set { progress = newValue } } func path(in rect: CGRect) -> Path { // 簡易的に 2 つのシェイプを切り替えるだけ(概念実装) if progress < 0.5 { return Circle().path(in: rect) } else { return StarShape(points: 5).path(in: rect) } } } // 星形の簡易実装 struct StarShape: Shape { var points: Int func path(in rect: CGRect) -> Path { // 実装は WWDC22 のサンプルを参照。ここでは省略。 Path() } } struct MorphDemo: View { @State private var toggle = false var body: some View { MorphShape(progress: toggle ? 1 : 0) .fill(Color.orange) .frame(width: 200, height: 200) .onTapGesture { withAnimation(.easeInOut(duration: 1.2)) { toggle.toggle() } } } } |
- ポイント:
animatableDataに変化させたい数値を保持し、path(in:)でその数値に応じた形状を生成すれば、任意のベクトルグラフィック変形が滑らかに表現できます。実務ではCGPathの点列を取得して線形補間する実装が一般的です。
iOS 19 / Xcode 16 で期待される新機能(※執筆時点は予測情報)
Apple は毎年 WWDC で次世代 OS と IDE を発表しますが、iOS 19 および Xcode 16 の正式リリース日は執筆時点では未確定です。そのため以下に示す内容は Apple が過去のプレビューや開発者向け情報で言及した「予定」や「予測」に基づくものです。実際の API 名称・挙動が変更される可能性がありますので、リリースノートを必ず確認してください。
interactiveSpring の正しい概要
Animation.interactiveSpring(response:dampingFraction:blendDuration:) は iOS 15 以降に導入された API であり、ユーザー入力(ドラッグ速度・方向)をリアルタイムでスプリング計算に反映します。iOS 19 ではこの API がさらに最適化され、パラメータの省略形 interactiveSpring() が追加される見込みです。
| パラメータ | 説明 |
|---|---|
response |
スプリングが目標位置に到達するまでの感覚的時間(秒) |
dampingFraction |
減衰率。0 〜 1 の範囲でバウンド感を制御 |
blendDuration |
アニメーション開始前の「ブレンド」時間 |
.spring と異なる点
- .spring は開始時に固定された速度・減衰で計算され、途中パラメータは変化しません。
- .interactiveSpring はジェスチャ情報を取得し続け、指を離した瞬間の速度ベクトルから自然な慣性運動を生成します。
実装例(iOS 17 で動作)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
struct InteractiveDemo: View { @State private var offset = CGSize.zero var body: some View { RoundedRectangle(cornerRadius: 16) .fill(Color.green) .frame(width: 120, height: 120) .offset(offset) // アクセシビリティ識別子を付与(UI テストで使用) .accessibilityIdentifier("draggableCard") .gesture( DragGesture() .onChanged { gesture in offset = gesture.translation } .onEnded { _ in // ユーザーが離した瞬間の速度情報をもとにスプリングで元位置へ戻す withAnimation(.interactiveSpring(response: 0.4, dampingFraction: 0.6, blendDuration: 0.25)) { offset = .zero } } ) } } |
- 適用シーン:カードスワイプ、モーダルのドラッグリサイズ、カスタムコントロールの「戻す」アクションなど。
interactiveSpring と matchedGeometryEffect の組み合わせ例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
struct InteractiveDetailDemo: View { @Namespace private var ns @State private var selected: Int? = nil @State private var dragOffset = CGSize.zero var body: some View { ZStack { if let id = selected { DetailView(id: id, namespace: ns) .offset(dragOffset) // アクセシビリティ識別子は UI テストで参照 .accessibilityIdentifier("detailCard") .gesture( DragGesture() .onChanged { dragOffset = $0.translation } .onEnded { _ in withAnimation(.interactiveSpring()) { dragOffset = .zero } } ) .transition(.scale.combined(with: .opacity)) } else { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) { ForEach(0..<6) { i in Card(id: i, namespace: ns) .onTapGesture { withAnimation(.easeInOut) { selected = i } } } } } } } } } |
- ポイント:ドラッグ中の速度情報が
interactiveSpringに渡され、指を離した瞬間に自然な慣性で元位置へ戻ります。Xcode 16 の Canvas でもリアルタイムプレビューが可能です。
注意:上記コードは現在の SwiftUI(iOS 17)でもコンパイルできますが、iOS 19 で追加される新しいオーバーロードやデフォルト引数に合わせて更新する必要があります。公式ドキュメントを必ず確認してください。
パフォーマンス測定・テスト・デバッグ
高度なアニメーションは見た目だけでなく、フレームレートやメモリ使用量がユーザー体感に直結します。この章では Instruments を用いた計測手順と、 XCTest UI テスト で自動検証する方法を具体的に示します。
Instruments で確認すべき指標
| ツール | 確認ポイント |
|---|---|
| Time Profiler | アニメーション開始から終了までの CPU 使用率。スパイクがあればロジックを分割するか、withAnimation の範囲を狭める。 |
| Core Animation | 「Frames」タブで 60 fps(iPhone 15)または 120 fps(ProMotion デバイス)が維持できているか。ドロップが頻繁なレイヤーは drawingGroup() でオフスクリーン合成を抑制。 |
| Memory | アニメーション中のメモリ増減。画像や大規模シェイプを毎フレーム生成していないか確認し、ImageRenderer のキャッシュ活用や CGPath 再利用で最適化する。 |
ベストプラクティス:iOS 19 では内部的に
GraphicsDeviceが GPU オフロードを自動最適化しますが、過剰なGeometryReaderやonAppear内の重い計算は依然としてボトルネックになるため、可能なら ViewModel 側で事前計算してから渡すようにしましょう。
Canvas プレビューとカスタムスライダー
Xcode 16 の Canvas では @State を操作するカスタムプレビューを作成でき、実機速度と比較しながら微調整が可能です。
|
1 2 3 4 5 6 7 8 |
struct InteractiveDemo_Preview: PreviewProvider { static var previews: some View { InteractiveDemo() .previewDisplayName("InteractiveSpring Demo") .previewLayout(.sizeThatFits) } } |
XCTest UI テストでアニメーション完了を検証
UI テストでは単に要素が存在するかだけでなく、位置やサイズが期待値に収束したこと を確認します。以下は先ほどの draggableCard がスプリングで元位置へ戻るまで待機する例です。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import XCTest final class AnimationUITests: XCTestCase { func testInteractiveSpringReturnsToOrigin() throws { let app = XCUIApplication() app.launch() // アクセシビリティ識別子が付与されたカードを取得 let card = app.otherElements["draggableCard"] XCTAssertTrue(card.waitForExistence(timeout: 2)) // カードをドラッグ let start = card.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)) start.press(forDuration: 0.2, thenDragTo: end) // スプリングが完了するまで最大1秒待機し、位置が (0,0) に戻っていることを確認 let predicate = NSPredicate(format: "frame.origin.x == 0 && frame.origin.y == 0") expectation(for: predicate, evaluatedWith: card, handler: nil) waitForExpectations(timeout: 1.5) } } |
- ポイント:
accessibilityIdentifierが無いと UI テスト側で要素を特定できません。実装側に必ず付与しましょう。
まとめ
| 項目 | 推奨シーン |
|---|---|
| 暗黙的アニメーション | 状態変化が単純で全体に同一イージングを適用したいとき |
| 明示的アニメーション | 非同期処理やジェスチャ駆動、複数ビューの個別制御が必要なとき |
withAnimation |
条件分岐やタスク完了後に限定してアニメーションさせる |
.animation 修飾子 |
リスト全体の並び替え・再ロードなど一括変更時 |
transition / matchedGeometryEffect |
画面遷移や要素間の位置合わせで自然な動きを実現 |
| カスタムベジェ曲線 | 標準イージングでは表現できないバウンス感やアクセントが必要なとき |
AnimatableModifier / Animatable |
パスやシェイプの連続変形、ロゴ・アイコンアニメーション |
.interactiveSpring |
ドラッグ操作に慣性・バウンド感を付与したいとき(iOS 19 で最適化予測) |
| Instruments + UI テスト | フレームレート・メモリ管理の可視化、CI 上で自動検証 |
- 暗黙的 vs 明示的は「シンプルさ」vs「制御性」のトレードオフです。まずは暗黙的で実装し、要件が増えたら明示的へ段階的に移行しましょう。
- カスタム曲線は
Animation.timingCurveを使えば安全に実装できます。TimingCurveProviderは存在しないので注意してください。 .interactiveSpringは既存 API ですが、iOS 19 での最適化や省略形が期待されます。リリース前に公式ドキュメントを必ず確認しましょう。- アクセシビリティ識別子は UI テストだけでなく、アクセシビリティ支援技術にも有用です。実装時に必ず付与してください。
上記のベストプラクティスとツール群を組み合わせることで、iOS 19 / Xcode 16(将来リリース予定)でも安定した高品質アニメーションが実現できます。ぜひサンプルプロジェクトをクローンし、コードを書き換えながら体感してみてください。