Реализация Matched Geometry Effect в iOS-приложении (SwiftUI)
matchedGeometryEffect — SwiftUI-механизм для анимации геометрического перехода между двумя View с одним идентификатором. При переключении состояния (показать/скрыть, развернуть/свернуть) SwiftUI интерполирует position, size и anchor point между парными элементами — результат выглядит как плавное «перетекание» одного в другой.
Мощный инструмент. И регулярный источник неожиданных артефактов, если не понимать, как он работает внутри.
Базовый принцип и типичные кейсы
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 + детальный оверлей) — правильная структура:
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-ируются по bounds контейнера. При расширении карточки анимация обрезается краем списка. Решение — выносить детальный вид из List в ZStack поверх него, как в паттерне выше.
Анимированный custom 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 с детальным оверлеем и корректной обработкой видимости: 1–2 дня. Анимированный tab bar или custom navigation indicator: несколько часов. Стоимость рассчитывается индивидуально.







