SwiftUI matchedGeometryEffect

matchedGeometryEffect は、異なるビュー間でスムーズな遷移アニメーションを実現する機能だ。あるビューが消えて別のビューが現れるとき、両者の位置やサイズが滑らかに補間され、まるで 1 つのビューが変形したかのような効果を生む。

基本的な使い方

matchedGeometryEffect を使うには、Namespace を定義し、遷移させたいビューに同じ ID を付ける。

struct ContentView: View {
    @Namespace private var animation
    @State private var isExpanded = false
    
    var body: some View {
        VStack {
            if isExpanded {
                RoundedRectangle(cornerRadius: 20)
                    .fill(.blue)
                    .matchedGeometryEffect(id: "shape", in: animation)
                    .frame(width: 300, height: 400)
            } else {
                RoundedRectangle(cornerRadius: 10)
                    .fill(.blue)
                    .matchedGeometryEffect(id: "shape", in: animation)
                    .frame(width: 100, height: 100)
            }
        }
        .onTapGesture {
            withAnimation(.spring()) {
                isExpanded.toggle()
            }
        }
    }
}

同じ ID “shape” を持つ 2 つのビューが、位置とサイズを滑らかに遷移する。Transition を使わなくても、ビューの切り替え時に自動的に補間される点が特徴だ。

Namespace の役割

@Namespace は、matchedGeometryEffect の ID が有効なスコープを定義する。同じ Namespace 内で同じ ID を持つビューがペアとして認識される。

@Namespace private var heroAnimation
@Namespace private var thumbnailAnimation

複数の Namespace を使い分けることで、異なるアニメーションセットを独立して管理できる。

properties パラメータ

matchedGeometryEffect は、何をマッチさせるかを properties パラメータで制御できる。

.matchedGeometryEffect(
    id: "shape",
    in: animation,
    properties: .frame  // デフォルト
)

.matchedGeometryEffect(
    id: "shape",
    in: animation,
    properties: .position
)

.matchedGeometryEffect(
    id: "shape",
    in: animation,
    properties: .size
)
.frame位置とサイズの両方をマッチ(デフォルト)
.position位置のみをマッチ
.sizeサイズのみをマッチ

isSource パラメータ

複数のビューが同じ ID を持つ場合、どちらを「基準」とするかを isSource で指定できる。

// 基準となるビュー
Circle()
    .matchedGeometryEffect(id: "dot", in: animation, isSource: true)

// 基準に合わせるビュー
Circle()
    .matchedGeometryEffect(id: "dot", in: animation, isSource: false)

isSource: false のビューは、isSource: true のビューの位置・サイズに合わせて配置される。タブバーの選択インジケーターなど、「追従する」動きを作るときに使う。

タブ選択インジケーターの例

struct TabBarView: View {
    @Namespace private var animation
    @State private var selectedTab = 0
    let tabs = ["Home", "Search", "Profile"]
    
    var body: some View {
        HStack {
            ForEach(tabs.indices, id: \.self) { index in
                Button(action: {
                    withAnimation(.spring()) {
                        selectedTab = index
                    }
                }) {
                    Text(tabs[index])
                        .padding(.vertical, 8)
                        .padding(.horizontal, 16)
                        .background {
                            if selectedTab == index {
                                Capsule()
                                    .fill(.blue.opacity(0.2))
                                    .matchedGeometryEffect(
                                        id: "tab",
                                        in: animation
                                    )
                            }
                        }
                }
                .foregroundColor(selectedTab == index ? .blue : .gray)
            }
        }
    }
}

選択インジケーター(背景の Capsule)が、タップしたタブへスムーズに移動する。

Hero アニメーション

一覧画面から詳細画面への遷移で、サムネイルが拡大するような「Hero アニメーション」も実現できる。

struct PhotoGridView: View {
    @Namespace private var animation
    @State private var selectedPhoto: Photo?
    
    var body: some View {
        ZStack {
            // グリッド表示
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                ForEach(photos) { photo in
                    Image(photo.name)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: 100, height: 100)
                        .clipped()
                        .matchedGeometryEffect(
                            id: photo.id,
                            in: animation
                        )
                        .onTapGesture {
                            withAnimation(.spring()) {
                                selectedPhoto = photo
                            }
                        }
                }
            }
            
            // 詳細表示
            if let photo = selectedPhoto {
                Image(photo.name)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .matchedGeometryEffect(
                        id: photo.id,
                        in: animation
                    )
                    .onTapGesture {
                        withAnimation(.spring()) {
                            selectedPhoto = nil
                        }
                    }
            }
        }
    }
}

グリッドのサムネイルをタップすると、その画像が拡大しながら詳細表示に変化する。閉じるときは逆に縮小してグリッドに戻る。

注意点

matchedGeometryEffect は強力だが、いくつか注意点がある。同じ Namespace 内で同じ ID を持つビューは、同時に 2 つ以上表示されると挙動が不安定になる。if-else で切り替える、または ZStack で重ねて opacity で表示を制御するといった工夫が必要だ。