🏝️

.matchedGeometryEffectを利用してCustomTransitionの様な表現をSwiftUIで作成する際のポイント解説

に公開

1. はじめに

サムネイルをタップするとシームレスに拡大表示へ遷移する――そんな“連続性のあるフォトギャラリー”を UIKit 時代は Custom Transition で構築してきました。見た目に美しいアニメーションやインタラクションを加えることで、機能そのものをより印象的に演出できる強力な手法です。

※UIKit での実装例は過去の記事をご参照ください。
https://qiita.com/fumiyasac@github/items/04c66743a3c829d39b1f

しかし、SwiftUIでは、同じ手法をそのまま適用するわけにはいきません。宣言的UIらしい記述で同様の体験を実現する鍵となるのがmatchedGeometryEffectです。

https://developer.apple.com/documentation/swiftui/view/matchedgeometryeffect(id:in:properties:anchor:issource:)

matchedGeometryEffectは、異なるView間に共通のジオメトリIDを与えることで、位置やサイズの遷移を自動的に補間し、まるで同一要素が画面を跨いで移動するかのような滑らかなアニメーションを提供してくれるAPIです。

本記事では、このAPIを活用し、 「サムネイル → 拡大画像 → ドラッグで閉じる」 という一連の流れを最小限のコードで実装する際のポイントを解説します。

2. 今回のサンプル概要&参考資料

本章では、matchedGeometryEffectを用いた 「サムネイル → 拡大画像 → ドラッグで閉じる」 の基本フローを、最小構成のサンプルアプリで検証します。まずは GitHub レポジトリ に用意したコードを手元でビルドし、実際の動きを確かめてみてください。サムネイルをタップすると画像が滑らかに拡大され、ドラッグ操作で元の一覧へ戻る一連のトランジションは、宣言的UIの恩恵でわずかなコード量に収まっています。

加えて、キーポイントごとにコメントを付けているため、Namespaceの定義からDragインタラクションの取り込み方まで、SwiftUIのAPI 呼び出しがどこで連携しているかを追いやすくなっています。続く節では、ファイル構成・主要コンポーネント・カスタムモディファイアの役割を順に読み解きながら、実装の勘所を整理していきましょう。

2-1. サンプル概要:

【動作確認用コード】

https://github.com/fumiyasac/CharacteristicStyleSwiftUIExample

【画像キャプチャ】

サムネイル一覧画面 画像拡大状態時
サムネイル一覧画面 画像拡大状態時

2-2. 参考記事:

https://zenn.dev/yamajyn/articles/473f2ae123769d

https://qiita.com/SNQ-2001/items/9d0963e3c2861593b58f

3. コードから読み解く実装ポイント解説

3‑1. 画面レイアウトと状態管理

GalleryScreenViewNavigationStack内に2列グリッド(LazyVGrid)を構成し、GalleryViewStateProviderから取得したGalleryViewObjectをサムネイルとして描画します。

画面遷移アニメーションで連携させるために、3つのプロパティを用意し、選択された要素と名前空間を共有し、グリッドと拡大ビューをつなぎます。

@State private var selectedViewObject: GalleryViewObject?
@State private var selectedImage: Image?
@Namespace private var namespace

3‑2. matchedGeometryEffectの設定ポイント

一意なIDを付与するために、ID文字列に行末の 「gallery-プレフィックス + モデルID」 を使う事で、元画像と拡大画像が同一のIDであることを保証します。加えて、第3引数のisSourceフラグの部分では、切替を一覧側では選択前にtrue、拡大側では選択後にfalse となるよう三項演算子で反転させています。これにより、どちらがアニメーションの起点かをSwiftUIに明示し、自然なズームイン/アウトを実現します。

.matchedGeometryEffect(
    id: "gallery-\(galleryViewObject.id)",
    in: namespace,
    isSource: selectedImage == nil
)

3‑3. オーバーレイによるレイヤリング

拡大ビューは.overlayモディファイアを利用してグリッドの最前面に重ねます。また、グリッド自体は.opacity(selectedImage == nil ? 1 : 0)とする事でインタラクションを無効化し、“裏に隠れる” 振る舞いを簡潔に表現しています。

.overlay {
    if selectedViewObject != nil {
        GallerySelectedImageView()
    }
}

3‑4. 拡大ビューとドラッグインタラクション

GallerySelectedImageViewでは、ドラッグ量を追跡し、offset(position)DragGesture上下スワイプによる閉じる操作 を実装しています。

@State private var position = CGSize.zero

処理の概要としては、

  1. 高さ方向200px以上ドラッグされたらselectedImage = nilで閉じる。
  2. 失敗した場合はposition = .zeroへスナップバックする。

という形を取る事になります。

いずれも下記の様な感じで、開閉・キャンセルを統一した動き にしています。

withAnimation(.spring(response: 0.36, dampingFraction: 0.48))

本サンプルの核心は、

  1. 一致する ID と名前空間を共有して、matchedGeometryEffectをリンクさせること
  2. 一覧と拡大のレイヤをシンプルに切り替え、状態駆動でアニメーションさせること
  3. DragGesture での解除ロジック をスプリングアニメーションに統一し、操作感を損なわないこと

の3点に集約されます。これらを押さえるだけで、SwiftUIでもUIKitのCustom Transitionに匹敵する “連続的かつ没入感のあるフォトギャラリー体験” を実装できます。

4. 完成コード例

4-1. 画面全体のView要素に関するコード:

GalleryScreenView.swift
import Extension
import NukeUI
import SwiftUI
import ViewStateProvider
import ViewObject

public struct GalleryScreenView: View {

    // MARK: - ViewStateProvider

    private let viewStateProvider: GalleryViewStateProvider

    // MARK: - Property (Grid Layout)

    private let gridColumns = Array(repeating: GridItem(.flexible()), count: 2)

    // MARK: - Property (Display Animation)

    // 選択された画面切り替え対象のViewObjectを格納する
    @State private var selectedViewObject: GalleryViewObject?

    // 選択された画面切り替え対象のImage要素を格納する
    // 👉 Image自体を渡して連続的なAnimationにする
    @State private var selectedImage: Image?

    // .matchedGeometryEffectで利用する名前空間
    @Namespace private var namespace

    // MARK: - Initializer

    public init(viewStateProvider: GalleryViewStateProvider = GalleryViewStateProvider()) {
        self.viewStateProvider = viewStateProvider
    }

    // MARK: - Body
    
    public var body: some View {
        NavigationStack {
            Group {
                if viewStateProvider.requestStatus == .success {
                    TwoGridColumnScrollView()
                } else {
                    // TODO: Error発生時のハンドリング処理を実施する
                    Text("GalleryScreen")
                }
             }
            .onFirstAppear {
                viewStateProvider.fetchGalleries()
            }
            .navigationTitle("🎨ギャラリー画面")
            .navigationBarTitleDisplayMode(.inline)
        }
    }

    @ViewBuilder
    private func TwoGridColumnScrollView() -> some View {
        VStack(alignment: .leading) {
            ScrollView {
                LazyVGrid(columns: gridColumns) {
                    ForEach(viewStateProvider.galleryViewObjects, id: \.id) { galleryViewObject in
                        LazyImage(url: galleryViewObject.thumbnailUrl) { imageState in
                            if let cachedImage = imageState.image {

                                // サムネイル画像が存在する場合はGrid表示をする
                                VStack (alignment: .leading) {
                                    cachedImage
                                        .resizable()
                                        .scaledToFit()
                                        // 拡大表示先のViewに配置した要素Imageと同様に一意なID文字列と名前空間を定める
                                        // 👉 第3引数の「isSource」は何が正解かイマイチ理解していない...
                                        .matchedGeometryEffect(id: "gallery-\(galleryViewObject.id)", in: namespace, isSource: selectedImage == nil)
                                        .onTapGesture {
                                            // 選択された画面切り替え対象のViewObjectとImage要素を変数に格納してAnimationを実行する
                                            withAnimation(.spring(response: 0.36, dampingFraction: 0.48)) {
                                                selectedImage = cachedImage
                                                selectedViewObject = galleryViewObject
                                            }
                                        }
                                    Text(galleryViewObject.title)
                                        .font(.subheadline)
                                        .bold()
                                        .foregroundStyle(.primary)
                                        .padding(.vertical, 4.0)
                                    Text(galleryViewObject.subtitle)
                                        .font(.footnote)
                                        .foregroundStyle(.secondary)
                                        .padding(.bottom, 4.0)
                                    Text("[公開日]:" + galleryViewObject.publishedAt)
                                        .font(.caption)
                                        .foregroundStyle(.secondary)
                                        .padding(.bottom, 8.0)
                                }
                            } else {

                                // サムネイル画像が存在しない場合は白色背景を表示する
                                Color(.white)
                            }
                        }
                    }
                }
                .padding(12.0)
            }
            // 拡大画像表示が選択されている時はGrid表示のアルファ値を0にして操作ができない様にする
            .opacity(selectedImage == nil ? 1 : 0)
        }
        .overlay {
            // .overlayを利用して拡大画像表示を上に重ねる
            if selectedViewObject != nil {
                GallerySelectedImageView(
                    selectedViewObject: $selectedViewObject,
                    selectedImage: $selectedImage,
                    namespace: namespace
                )
            }
        }
    }
}

4-2. 写真拡大表示時に利用するView要素に関するコード:

GallerySelectedImageView.swift
import SwiftUI
import ViewObject

struct GallerySelectedImageView: View {

    // MARK: - Property (Display Animation)

    // 選択された画面切り替え対象のViewObjectと連動する
    @Binding var selectedViewObject: GalleryViewObject?

    // 選択された画面切り替え対象のImage要素と連動する
    @Binding var selectedImage: Image?

    // .matchedGeometryEffectで利用する名前空間
    let namespace: Namespace.ID

    // MARK: - Property (DragGesture Translation)

    // DragGestureでの変化量を格納する
    @State private var position = CGSize.zero

    // MARK: - Body

    var body: some View {
        VStack {
            if let id = selectedViewObject?.id, let cachedSelectedImage = selectedImage {

                // 拡大画像が存在する場合は比率を維持した状態で横幅いっぱいに表示する
                cachedSelectedImage
                    .resizable()
                    .scaledToFit()
                    .matchedGeometryEffect(id: "gallery-\(id)", in: namespace, isSource: selectedImage != nil)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.36, dampingFraction: 0.48)) {
                            selectedImage = nil
                            selectedViewObject = nil
                        }
                    }
                    // DragGestureでの変化量と要素が追従する様にoffset値を更新する
                    .offset(position)
                    // DragGestureを実行し、①移動中は変化量を変数に格納する、②終了時は高さの閾値を見て超過する場合は表示を元に戻す、の2つを実行する
                    .gesture(
                        DragGesture()
                            .onChanged { value in
                                position = value.translation
                            }
                            .onEnded { _ in
                                withAnimation(.spring(response: 0.36, dampingFraction: 0.48)) {
                                    if 200.0 < abs(position.height) {
                                        selectedImage = nil
                                        selectedViewObject = nil
                                    } else {
                                        position = .zero
                                    }
                                }
                            }
                    )

            } else {

                // 拡大画像が存在しない場合は白色背景を表示する
                Color(.white)
            }
        }
        .padding()
    }
}

5. まとめ

.matchedGeometryEffectを活用すれば、UIKit時代にCustom Transitionで実装していた “サムネイルからディテールへ滑らかに繋がる体験” を宣言的なSwiftUIの流儀で近い形を再現できます。

実装における重要な点は下記の3点になると思います。

  1. IDと名前空間の一貫性
  • 一覧側と拡大側で同一IDを共有し、isSourceを状態に応じて反転させることでズームイン/アウトの起点を明示する。
  1. レイヤリングと状態駆動
  • overlayopacityの組み合わせを利用して、表示レイヤーの切替えを実装する。
  1. 統一スプリングとドラッグジェスチャー
  • 開閉/スナップバック時における操作の一貫性と心地よさを両立し、ドラッグ距離の閾値で「閉じる・戻る」を判定するだけで直感的な操作感を実現する。

SwiftUI はまだ進化の途中にありますが、matchedGeometryEffectのような強力なAPIを使いこなせば、 “宣言的なのにリッチ” という理想に一歩近づけます。ぜひ本記事のサンプルコードをベースに、自分のアプリでも連続性のあるトランジションを試してみてください。

Discussion