Tap a thumbnail, watch it expand into its detail screen. Photos and the App Store do this everywhere.
Getting it yourself used to mean matchedGeometryEffect wired through a manual ZStack toggle. That was fighting NavigationStack instead of using it, and it cost you interactive back-swipe along the way.
#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 share an ID and a namespace. That's the whole contract: 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. Both views also need the same @Namespace.
The namespace is the half that usually breaks. It gets declared in the parent while the transition is attached to the pushed destination, so it has to be threaded down through every intermediate view. Easy to forget.
Nothing tells you when you miss it. No crash, no warning. The push silently falls back to the default slide animation instead of zooming.