Using Sheets, Transitions, and Offsets to create a popover in SwiftUI | SwiftUI Bootcamp #29
SwiftUI's built-in .sheet is powerful but inflexible — you can't control its height precisely or position it exactly where you want. This lesson compares three techniques for presenting a custom overlay: system sheets, conditional transitions, and offset-based animation. Each has different trade-offs, and knowing all three means you'll always have the right tool for the job.
What You'll Learn
- Three distinct techniques for presenting an overlay screen:
.sheet, transition, and offset animation - How to use
.offset(y:)with a spring animation to create a custom slide-up panel - When to use
@Bindingto let a child view dismiss itself by communicating back to the parent
Mental Model
Imagine you're moving furniture into a room. Method 1 (sheet) is like using a service elevator — the furniture arrives on a separate platform, you don't control the ride, but it's reliable. Method 2 (transition) is like carrying furniture through a door — you're in charge of the path but the furniture only exists while it's walking through. Method 3 (offset) is like furniture that's always in the room but stored under a trapdoor in the floor — it's always there, just hidden below the visible area, and slides up smoothly when you open the hatch.
Method 3 (the offset approach) is the most flexible for custom bottom-sheet-style UI, because the view is always in the layout, always animated by the spring, and requires no conditional logic to trigger the transition.
Detailed Explanation
Method 1 (.sheet): Uses SwiftUI's built-in presentation system. You get automatic drag-to-dismiss, presentation callbacks, and standard iOS behavior. The trade-off is that you have limited control over the sheet's initial height, detents (iOS 16+), or corner radius in older iOS versions.
Method 2 (transition): Conditionally inserts the view inside a ZStack and uses withAnimation + .transition(.move(edge: .bottom)) to animate it. This gives you full control over appearance and position, but requires the if conditional, which means the view's @State resets each time it disappears.
Method 3 (offset): The view is always in the hierarchy. When showNewScreen = false, a large positive Y offset pushes it completely off the bottom of the screen. When showNewScreen = true, the offset resets to 0, and a spring animation slides it into position. Because the view always exists, its local state persists across show/hide cycles.
For Method 3, @Binding is essential: the child view (NewScreen) needs to dismiss itself, but it doesn't own the showNewScreen state — the parent does. Passing a @Binding var showNewScreen: Bool into NewScreen allows the child's X button to write showNewScreen = false without needing @Environment(\.presentationMode).
Code Structure
PopoverBootcamp.swift contains PopoverBootcamp (the parent with @State var showNewScreen) and NewScreen (which accepts both @Environment(\.presentationMode) and @Binding var showNewScreen). The active code uses Method 3 (offset). Methods 1 and 2 are preserved as commented-out code blocks with labels, making it easy to switch between them and compare behavior.
Complete Code
PopoverBootcamp.swift
// sheets
// animations
// transitions
import SwiftUI
struct PopoverBootcamp: View {
@State var showNewScreen: Bool = false // single flag controls all three method variants
var body: some View {
ZStack {
Color.orange
.edgesIgnoringSafeArea(.all)
VStack {
Button("BUTTON") {
showNewScreen.toggle() // toggles the overlay — no withAnimation needed for Method 3 (offset uses .animation on the view)
}
.font(.largeTitle)
Spacer()
}
// METHOD 1 - SHEET
// .sheet(isPresented: $showNewScreen, content: {
// NewScreen()
// })
// METHOD 2 - TRANSITION
// ZStack {
// if showNewScreen {
// NewScreen(showNewScreen: $showNewScreen)
// .padding(.top, 100)
// .transition(.move(edge: .bottom)) // slides in from bottom on insertion
// .animation(.spring()) // spring curve for the transition
// }
// }
// .zIndex(2.0) // ensures the overlay sits above all other content
// METHOD 3 - ANIMATION OFFSET
NewScreen(showNewScreen: $showNewScreen)
.padding(.top, 100) // leaves space for the parent's button above
.offset(y: showNewScreen ? 0 : UIScreen.main.bounds.height) // 0 = visible; full height = hidden below screen
.animation(.spring()) // spring animates the offset change whenever showNewScreen changes
}
}
}
struct NewScreen: View {
@Environment(\.presentationMode) var presentationMode // needed if this view is presented via .sheet (Method 1)
@Binding var showNewScreen: Bool // needed for Method 2 and Method 3 to dismiss without .sheet
var body: some View {
ZStack(alignment: .topLeading) {
Color.purple
.edgesIgnoringSafeArea(.all)
Button(action: {
//presentationMode.wrappedValue.dismiss() // use this for Method 1 (sheet)
showNewScreen.toggle() // use this for Method 2 (transition) and Method 3 (offset)
}, label: {
Image(systemName: "xmark")
.foregroundColor(.white)
.font(.largeTitle)
.padding(20)
})
}
}
}
struct PopoverBootcamp_Previews: PreviewProvider {
static var previews: some View {
PopoverBootcamp()
//NewScreen()
}
}Code Walkthrough
@State var showNewScreen: Bool = false— The single piece of state that drives all three methods. For Method 3, when this isfalse, the view is off-screen. Whentrue, it's on-screen.NewScreen(showNewScreen: $showNewScreen)(Method 3) —NewScreenis always in the ZStack. It's not conditional. It's always rendered, always part of the layout — just visually off-screen..padding(.top, 100)— OffsetsNewScreendownward by 100 points so the bottom of the parent's VStack (with the button) peeks above the overlay. This is how you create a "peek" effect on a custom sheet..offset(y: showNewScreen ? 0 : UIScreen.main.bounds.height)— The key to Method 3.UIScreen.main.bounds.heightis the full device height — a positive Y offset this large moves the view completely below the visible screen. WhenshowNewScreenistrue, offset is 0 (normal position)..animation(.spring())— Attaches a spring animation to this view. Any timeshowNewScreenchanges, the.offsetvalue changes, and the spring animation interpolates between the old and new offset values. NowithAnimation {}needed at the call site.@Binding var showNewScreen: BoolinNewScreen— The child writesshowNewScreen.toggle()in the X button's action, which sets the parent's state back tofalse. This is the correct pattern when a child view needs to dismiss itself in the offset/transition methods.
Common Mistakes
Mistake: Using Method 3 (offset) but forgetting to add .animation() to the view
Without .animation(.spring()), the offset change happens instantly — the view teleports instead of slides. The animation must be on the same view as the .offset modifier.
Mistake: Trying to pass @Binding to a view presented via .sheet
When a view is presented with .sheet, passing @Binding and calling showNewScreen.toggle() to dismiss works — but you should prefer @Environment(\.presentationMode) or @Environment(\.dismiss) for sheet-presented views because SwiftUI may manage the Boolean's reset automatically.
Mistake: Using zIndex in Method 2 but forgetting it causes the overlay to intercept all taps
When a ZStack overlay with high zIndex is visible, it catches all touches in its frame — even if visually transparent areas exist. Ensure you only apply zIndex to the overlay wrapper, not the entire parent ZStack.
Key Takeaways
- Three valid techniques exist for custom overlays: system
.sheet, conditionaltransition, and always-presentoffset. Each has different control trade-offs. - The offset method keeps the child view in the hierarchy, preserving its local state across show/hide cycles — a key advantage over the transition approach.
- When a child view needs to dismiss itself outside of
.sheet, pass@Binding var showNewScreen: Booland callshowNewScreen.toggle()in the dismiss action.
Last updated: June 27, 2026