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 で表示を制御するといった工夫が必要だ。