Making a tapped thumbnail visually expand into its detail screen — the effect Photos and App Store use everywhere — used to mean matchedGeometryEffect wired through a manual ZStack toggle, fighting NavigationStack instead of using it, and losing interactive back-swipe in the process.
#The old way
@Namespace private var ns
@State private var showDetail = false
ZStack {
if !showDetail {
thumbnail
.matchedGeometryEffect(id: "photo", in: ns)
.onTapGesture { withAnimation { showDetail = true } }
} else {
DetailView()
.matchedGeometryEffect(id: "photo", in: ns)
}
}
#The new way
struct PhotoGridView: View {
@Namespace private var namespace
let photos: [Photo]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(photos) { photo in
NavigationLink {
PhotoDetailView(photo: photo)
.navigationTransition(.zoom(sourceID: photo.id, in: namespace))
} label: {
Image(photo.thumbnailName)
.resizable()
.aspectRatio(1, contentMode: .fill)
}
.matchedTransitionSource(id: photo.id, in: namespace)
}
}
}
}
}
}
struct PhotoDetailView: View {
let photo: Photo
var body: some View {
Image(photo.fullImageName)
.resizable()
.scaledToFit()
}
}
#Why it matters
- Works with
NavigationStackpush and pop directly, including interactive back-swipe - Source and destination only need to share an ID and a namespace — no manual frame or position math
- Reproduces the system zoom transition Apple uses in Photos and App Store, for free
#Gotcha
The ID passed to .matchedTransitionSource(id:in:) and to .navigationTransition(.zoom(sourceID:in:)) must match exactly, and both views need the same @Namespace. Since the namespace is declared in the parent but the transition is attached to the pushed destination, it's easy to forget to thread it down through intermediate views — when that happens there's no crash or warning, the push just silently falls back to the default slide animation instead of zooming.