An overlay draws one view on top of another. Beyond decoration, it solves two real problems:
keeping a view's structural identity, and building hero-style transitions that
NavigationStack cannot.
Keep Structural Identity
An if/else in a ViewBuilder creates two different views. Each toggle destroys one branch
and builds the other, so a custom button loses its state every time.
// Loses state: SwiftUI rebuilds the button on every toggle.
if isDownloading {
ProgressView()
} else {
DownloadButton("Download") { /* ... */ }
}
Keep one view alive and layer the conditional part in an overlay instead. disabled is an inert
modifier, so the button's identity survives.
DownloadButton { /* start download */ }
.disabled(isDownloading)
.overlay {
if isDownloading {
ProgressView()
}
}
Custom Transitions Without Navigation
NavigationStack transitions cannot be customized, and matchedGeometryEffect does not work
across a push. An overlay driven by selection state gives you the hero animation instead.
@State private var selectedImage: String?
@Namespace private var hero
LazyVGrid(columns: Array(repeating: .init(.flexible()), count: 3)) {
ForEach(images, id: \.self) { image in
Image(systemName: selectedImage == image ? "" : image)
.resizable()
.scaledToFit()
.matchedGeometryEffect(id: image, in: hero)
.onTapGesture { selectedImage = image }
}
}
.overlay {
if let image = selectedImage {
Image(systemName: image)
.resizable()
.scaledToFill()
.matchedGeometryEffect(id: image, in: hero)
.onTapGesture { selectedImage = nil }
}
}
.animation(.default, value: selectedImage)
The grid cell and the overlay share one geometry id, so selecting a cell morphs it into the full-size view. You own the navigation state yourself, which is the price of the effect.