Skip to content

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 @Binding to 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

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

  1. @State var showNewScreen: Bool = false — The single piece of state that drives all three methods. For Method 3, when this is false, the view is off-screen. When true, it's on-screen.

  2. NewScreen(showNewScreen: $showNewScreen) (Method 3)NewScreen is always in the ZStack. It's not conditional. It's always rendered, always part of the layout — just visually off-screen.

  3. .padding(.top, 100) — Offsets NewScreen downward 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.

  4. .offset(y: showNewScreen ? 0 : UIScreen.main.bounds.height) — The key to Method 3. UIScreen.main.bounds.height is the full device height — a positive Y offset this large moves the view completely below the visible screen. When showNewScreen is true, offset is 0 (normal position).

  5. .animation(.spring()) — Attaches a spring animation to this view. Any time showNewScreen changes, the .offset value changes, and the spring animation interpolates between the old and new offset values. No withAnimation {} needed at the call site.

  6. @Binding var showNewScreen: Bool in NewScreen — The child writes showNewScreen.toggle() in the X button's action, which sets the parent's state back to false. 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, conditional transition, and always-present offset. 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: Bool and call showNewScreen.toggle() in the dismiss action.

Last updated: June 27, 2026

Released under the MIT License.