Implementing Matched Geometry Effect in iOS Applications (SwiftUI)
matchedGeometryEffect is a SwiftUI mechanism for animating geometric transitions between two Views with the same identifier. When toggling state (show/hide, expand/collapse), SwiftUI interpolates position, size, and anchor point between paired elements—the result looks like smooth "flowing" of one into the other.
A powerful tool. And a regular source of unexpected artifacts if you don't understand how it works internally.
Basic Principle and Typical Cases
matchedGeometryEffect requires two things: a Namespace (shared identifier space) and matching id on the View pair. isSource: true marks the View as the source for geometry calculation.
struct ExpandableCard: View {
@State private var isExpanded = false
@Namespace private var cardNamespace
var body: some View {
if isExpanded {
// Full-screen view
VStack {
Image("product")
.resizable()
.matchedGeometryEffect(id: "product-image", in: cardNamespace)
.frame(maxWidth: .infinity)
.frame(height: 300)
Text("Detailed description...")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isExpanded = false
}}
} else {
// Card in list
HStack {
Image("product")
.resizable()
.matchedGeometryEffect(id: "product-image", in: cardNamespace)
.frame(width: 80, height: 80)
.cornerRadius(8)
Text("Brief title")
}
.onTapGesture { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isExpanded = true
}}
}
}
}
Pitfalls and How to Avoid Them
Problem 1: Both Views render simultaneously. matchedGeometryEffect doesn't hide elements automatically—if both Views exist in the hierarchy simultaneously, you see both. The if/else pattern above is correct: only one variant exists at a time.
For lists with many elements (LazyVGrid + detail overlay), the correct structure is:
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 } }
}
}
In ProductCard: if isSelected == true, hide the original via .opacity(0)—the position in grid remains but the element is invisible. matchedGeometryEffect continues using its geometry as the source.
Image(product.imageName)
.matchedGeometryEffect(id: "product-\(product.id)", in: gridNamespace,
isSource: !isSelected)
.opacity(isSelected ? 0 : 1)
Problem 2: Namespace only works within one View tree. @Namespace can't be passed through NavigationLink to another screen—they're in separate hierarchies. matchedGeometryEffect works only within one body or by passing Namespace.ID as a parameter down the tree. For cross-screen transitions via NavigationStack, use iOS 18 NavigationTransition API or custom AnyTransition.
Problem 3: Layout loop. If two Views with isSource: true and the same id exist in one container simultaneously, SwiftUI enters a layout loop. Console: "Bound preference ... tried to update multiple times per frame". Always one source only.
Problem 4: Animation clipped. Views inside List or ScrollView are clipped by container bounds. When a card expands, animation clips at the list edge. Solution: place the detail view outside the List in a ZStack above it, as in the pattern above.
Animated Custom Tab Bar
Popular use case: the active tab indicator smoothly moves between tabs:
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))
}
}
The indicator is one View with matchedGeometryEffect, which "jumps" between tab positions via spring. This works because matchedGeometryEffect with one id in ForEach applies to the single element where the condition is true.
Timeline
Expandable card with matchedGeometryEffect (single card) takes 0.5–1 day. LazyGrid with detail overlay and correct visibility handling takes 1–2 days. Animated tab bar or custom navigation indicator takes a few hours. Cost is calculated individually.







