Contents
1️⃣ 標準トランジションの限界
SwiftUI が標準で提供する opacity、slide、move などは 「表示 ⇄ 非表示」 を前提にしたシンプルなアニメーションです。
| トランジション | 主な挙動 | 複合・非線形遷移への対応可否 |
|---|---|---|
opacity |
0 → 1 のフェードイン/アウト | ❌(スケールや回転は不可) |
slide / move |
画面端からの平行移動 | ❌(サイズ変化を伴うレイアウトシフトは不可) |
scale |
拡大縮小のみ | ❌(同時に位置・回転を変えられない) |
典型的な失敗例
- カード一覧 → 詳細画面
カードが拡大しつつ位置も変わる場合、slideだけではサイズが固定されたままになる。 - 非線形イージングの適用
spring(response:dampingFraction:)のようなカスタムイーズは標準トランジションでは指定できない。
結論:複数プロパティを同時に変化させる、または物理ベースのイージングを使う場合は カスタムトランジションが必須 です。
2️⃣ AnyTransition.modifier と AnimatableModifier で作る基本的なカスタム遷移
2‑1. コアコンセプト
AnyTransition.modifier(active:identity:)は 任意の ViewModifier を「開始状態」と「終了状態」に分けて定義できる。AnimatableModifierが提供するanimatableDataに数値型を置くと、SwiftUI が自動で補間してくれる。
2‑2. 実装例(Scale + Fade)
|
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 |
import SwiftUI /// 0〜1 の進行度に応じてスケールと透明度を変化させる Modifier struct ScaleFadeModifier: AnimatableModifier { var progress: CGFloat // 補間値 (0 = 非表示, 1 = 完全表示) // SwiftUI が補間対象として認識するプロパティ var animatableData: CGFloat { get { progress } set { progress = newValue } } func body(content: Content) -> some View { content .scaleEffect(0.5 + 0.5 * progress) // 0.5 → 1.0 に拡大 .opacity(progress) // フェードイン } } // AnyTransition のエクステンションとして公開 extension AnyTransition { static var scaleFade: AnyTransition { .modifier( active: ScaleFadeModifier(progress: 0), identity: ScaleFadeModifier(progress: 1) ) } } |
使用例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@State private var showDetail = false var body: some View { VStack { if showDetail { DetailView() .transition(.scaleFade) // ← カスタム遷移を適用 } else { CardListView() } } .animation(.easeInOut(duration: 0.35), value: showDetail) } |
ポイント:
ScaleFadeModifierのみでスケールとフェードの同時変化が実現でき、コード量が極めてコンパクトになる。
3️⃣ 複合トランジション ― .combined(with:) の活用
3‑1. 基本構文
|
1 2 |
let custom = transitionA.combined(with: transitionB) |
transitionA と transitionB が内部で保持する modifier を 同時に適用 した新しい AnyTransition が生成されます。
3‑2. 実装例(回転 + スケール)
|
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 |
// 回転用 Modifier struct RotateModifier: AnimatableModifier { var angle: Angle var animatableData: Double { get { angle.degrees } set { angle = .degrees(newValue) } } func body(content: Content) -> some View { content.rotationEffect(angle) } } // AnyTransition の定義 extension AnyTransition { static var rotateScale: AnyTransition { let rotate = AnyTransition.modifier( active: RotateModifier(angle: .degrees(-90)), identity: RotateModifier(angle: .zero) ) // SwiftUI 標準のスケール遷移を利用 let scale = AnyTransition.scale return rotate.combined(with: scale) // ← 合成 } } |
使用例
|
1 2 3 4 5 6 7 8 9 10 |
@State private var isExpanded = false var body: some View { Image(systemName: "star.fill") .font(.system(size: 80)) .foregroundColor(.yellow) .transition(.rotateScale) // 回転しながら拡大縮小 .onTapGesture { isExpanded.toggle() } } |
ポイント:個別に定義した遷移を組み合わせるだけで、回転 + スケール といった高度なエフェクトが 10 行程度で完成します。
4️⃣ iOS 17 / Xcode 15 の新機能とリッチ遷移パターン
4‑1. .animation(_:value:)(Xcode 15)
|
1 2 |
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: isPresented) |
- 状態 → アニメーション の紐付けが明示的になるため、他のアニメーションと衝突しにくい。
valueが変化した瞬間だけアニメーションが走るので、不要な再描画を防げます。
4‑2. matchedGeometryEffect とカスタム Modifier の組み合わせ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Namespace private var ns // 名前空間はビュー階層で共有 struct CardItem: View { let id: UUID @Binding var selectedID: UUID? var body: some View { VStack { Image("cover") .resizable() .matchedGeometryEffect(id: "image\(id)", in: ns) Text("Title") .matchedGeometryEffect(id: "title\(id)", in: ns) } // カスタムフェード+スケール .modifier(ScaleFadeModifier(progress: selectedID == id ? 1 : 0)) .onTapGesture { selectedID = id } } } |
- 画像とテキストが同一 ID で共有され、別画面へ遷移した瞬間に位置・サイズが自然に補間される。
ScaleFadeModifierが同時にフェードイン/アウトを付与し、App Store のカード展開に近い演出が完成。
参考記事: 「SwiftUI で作るカスタムトランジション」(Zenn) – https://zenn.dev/yourname/articles/custom-transition-swiftui
4‑3. 完全サンプル(カード一覧 → 詳細)
|
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 |
struct ContentView: View { @State private var selectedID: UUID? = nil @Namespace private var ns let cards = (0..<5).map { _ in UUID() } var body: some View { ZStack { if let id = selectedID { DetailView(id: id, namespace: ns) .transition(.scaleFade) // カスタム遷移 .onTapGesture { withAnimation { selectedID = nil } } } else { ScrollView { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { ForEach(cards, id: \.self) { id in CardItem(id: id, selectedID: $selectedID) .frame(height: 150) .background(Color.gray.opacity(0.2)) .cornerRadius(12) } } .padding() } } } // 状態変化に対してだけアニメーションを走らせる .animation(.easeInOut(duration: 0.4), value: selectedID) } } |
5️⃣ 実務でのデバッグ・パフォーマンス最適化
| 項目 | 推奨手法 |
|---|---|
| プレビューで遷移確認 | @State static var previewToggle = false と DispatchQueue.main.asyncAfter を組み合わせ、1 秒後に自動トリガー。 |
| 無限再描画の防止 | - AnimatableModifier の animatableData に不要なプロパティを入れない- 大規模ビューは EquatableView でラップし差分判定を高速化 |
| 状態管理の粒度 | @StateObject → ビジネスロジック、@ObservedObject → UI だけに絞る。変更頻度が高いプロパティは別の @State に切り出す。 |
| メモリリーク対策 | クロージャ内で self を [weak self] 捕捉し、特に Timer・DispatchQueue 系統の非同期処理で注意する。 |
Xcode Preview の自動トリガー例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct CardPreview_Previews: PreviewProvider { @State static var showDetail = false static var previews: some View { ContentView() .previewLayout(.sizeThatFits) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { withAnimation { showDetail.toggle() } } } } } |
6️⃣ まとめ
- 標準トランジションはシンプル(フェード・スライド)に限定され、複合や非線形アニメーションには不向き。
AnyTransition.modifier+AnimatableModifierがカスタム遷移の基礎であり、任意プロパティを滑らかに補間できる。.combined(with:)によってトランジション同士を合成すれば、回転 + スケールやフェード + シフトといった多彩な演出が数行で実装可能。- iOS 17 / Xcode 15 の
.animation(_:value:)とmatchedGeometryEffectを併用すれば、App Store 風カード展開やモーダルシートのリッチ遷移を簡単に作れる。 - 実務ではプレビューでの自動トリガー確認と、無限再描画防止策・状態管理の粒度調整が必須。
これらのテクニックを組み合わせることで、「すぐに使える」オリジナル遷移を iOS 17・Swift 5.9 環境で構築でき、ユーザー体験を格段に向上させることができます。