Впровадження Matched Geometry Effect в iOS-додатки (SwiftUI)
matchedGeometryEffect — SwiftUI-механізм для анімації геометричного переходу між двома View з однаковим ідентифікатором. При перемиканні стану (показати/приховати, розширити/згорнути) SwiftUI інтерполює позицію, розмір та точку якоря між парними елементами—результат виглядає як плавне «перетікання» одного в інший.
Потужний інструмент. І регулярне джерело несподіваних артефактів, якщо ви не розумієте, як він працює всередину.
Базовий принцип та типові кейси
matchedGeometryEffect потребує двох речей: Namespace (спільний простір ідентифікаторів) та однаковий id у пари View. isSource: true позначає View як джерело для розрахунку геометрії.
struct ExpandableCard: View {
@State private var isExpanded = false
@Namespace private var cardNamespace
var body: some View {
if isExpanded {
// Повноекранний вигляд
VStack {
Image("product")
.resizable()
.matchedGeometryEffect(id: "product-image", in: cardNamespace)
.frame(maxWidth: .infinity)
.frame(height: 300)
Text("Детальний опис...")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isExpanded = false
}}
} else {
// Карточка в списку
HStack {
Image("product")
.resizable()
.matchedGeometryEffect(id: "product-image", in: cardNamespace)
.frame(width: 80, height: 80)
.cornerRadius(8)
Text("Коротка назва")
}
.onTapGesture { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isExpanded = true
}}
}
}
}
Підводні камені та як їх уникати
Проблема 1: обидва View рендеруються одночасно. matchedGeometryEffect не приховує елементи автоматично—якщо обидва View існують в ієрархії одночасно, ви бачите обидва. Паттерн if/else вище — правильний: у кожний момент часу існує лише один варіант View.
Для списків з багатьма елементами (LazyVGrid + detail overlay), правильна структура:
ZStack {
LazyVGrid(columns: ...) {
ForEach(products) { product in
ProductCard(product: product, namespace: gridNamespace,
isSelected: selectedProduct?.id == product.id)
.onTapGesture { withAnimation(.spring()) { selectedProduct = product } }
}
}
if let selected = selectedProduct {
ProductDetail(product: selected, namespace: gridNamespace)
.onTapGesture { withAnimation(.spring()) { selectedProduct = nil } }
}
}
У ProductCard: якщо isSelected == true, приховуйте оригінал через .opacity(0)—позиція в сітці залишається, але елемент невидимий. matchedGeometryEffect продовжує використовувати його геометрію як джерело.
Image(product.imageName)
.matchedGeometryEffect(id: "product-\(product.id)", in: gridNamespace,
isSource: !isSelected)
.opacity(isSelected ? 0 : 1)
Проблема 2: namespace працює тільки в межах одного View-дерева. @Namespace не можна передати через NavigationLink на інший екран—вони в різних ієрархіях. matchedGeometryEffect працює лише в межах одного body або передачею Namespace.ID як параметр вниз по дереву. Для міжекранних переходів через NavigationStack—використовуйте iOS 18 NavigationTransition API або кастомний AnyTransition.
Проблема 3: layout loop. Якщо два View з isSource: true та одним id існують в одному контейнері одночасно, SwiftUI входить у layout loop. Консоль: "Bound preference ... tried to update multiple times per frame". Завжди лише один джерело.
Проблема 4: анімація обрізується. View всередину List або ScrollView clip-уються по межах контейнера. При розширенні карточки анімація обрізується краєм списку. Рішення—винесіть detail view з List в ZStack поверх, як у паттерні вище.
Анімований власний Tab Bar
Популярний кейс: активний індикатор tab bar плавно переміщується між вкладками:
struct AnimatedTabBar: View {
@State private var selectedTab = 0
@Namespace private var tabNamespace
let tabs = ["house", "magnifyingglass", "heart", "person"]
var body: some View {
HStack {
ForEach(tabs.indices, id: \.self) { index in
ZStack {
if selectedTab == index {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.opacity(0.15))
.matchedGeometryEffect(id: "tab-indicator", in: tabNamespace)
.frame(width: 48, height: 36)
}
Image(systemName: tabs[index])
.foregroundColor(selectedTab == index ? .blue : .gray)
}
.frame(maxWidth: .infinity)
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
selectedTab = index
}
}
}
}
.padding(8)
.background(Color(.systemBackground))
}
}
Індикатор — один View з matchedGeometryEffect, який «стрибає» між позиціями вкладок через spring. Це працює тому, що matchedGeometryEffect з одним id у ForEach застосовується до єдиного елемента, де умова істина.
Час розробки
Expandable card з matchedGeometryEffect (одна карточка) займає 0,5–1 день. LazyGrid з detail overlay та правильною обробкою видимості займає 1–2 дні. Анімований tab bar або власний navigation indicator займає кілька годин. Вартість розраховується індивідуально.







