Skip to content

Display pop-up Sheets and FullScreenCovers in SwiftUI | SwiftUI Bootcamp #28

Tapping a button to open a settings screen, a photo picker, or an onboarding flow is one of the most common patterns in iOS. SwiftUI provides sheet and fullScreenCover as first-class modifiers that tie presentation to a Boolean state — no present(_:animated:), no view controller dance.

What You'll Learn

  • How to present a secondary screen using .sheet(isPresented:content:) and the difference from .fullScreenCover
  • How to dismiss a presented sheet programmatically using @Environment(\.presentationMode)
  • Why sheet presentation in SwiftUI is driven by state, and what "do not add conditional logic" in the content closure means

Mental Model

Think of .sheet like a trapdoor under a theater stage. The main stage (SheetsBootcamp) is always present. When the Boolean flips to true, the trapdoor opens and a new performer (SecondScreen) rises up on a platform — partially covering the main stage. The main stage is still there behind it; you can see its edge. When the new screen dismisses (trapdoor closes), it drops back down and the main stage is fully visible again.

.fullScreenCover is the same idea but with a full wall instead of a trapdoor — the new screen completely replaces what you see, with no peek of the original screen below.

Detailed Explanation

.sheet(isPresented: $showSheet, content: { SecondScreen() }) is a modifier you attach to any view. When showSheet becomes true, SwiftUI presents the content as a modal sheet. When showSheet returns to false (either by swiping down or by explicit dismissal code), the sheet disappears.

The content closure runs once when the sheet is about to be presented. The comment // DO NOT ADD CONDITIONAL LOGIC in the code refers to a real gotcha: if you write if someCondition { ScreenA() } else { ScreenB() } inside the content closure, SwiftUI evaluates the condition at presentation time and cannot update the content while the sheet is open. Keep the closure simple: return exactly one view.

@Environment(\.presentationMode) gives the child view access to its own presentation context. Calling presentationMode.wrappedValue.dismiss() sends a signal back to the parent to set showSheet = false. This is the SwiftUI 1.0/2.0 API. In iOS 15+, @Environment(\.dismiss) is the cleaner alternative: dismiss() directly.

The difference between sheet and fullScreenCover: a sheet slides up from the bottom and shows the underlying view peeking above it (the user can swipe it down). A fullScreenCover covers the entire screen with no underlying view visible and cannot be swiped down by default. Use sheet for contextual overlays; use fullScreenCover for onboarding, login, or any flow where you don't want the user to casually swipe back.

Code Structure

SheetsBootcamp.swift contains two structs: SheetsBootcamp (the presenting view that owns showSheet) and SecondScreen (the presented view that uses @Environment to dismiss itself). The commented-out .fullScreenCover line shows how trivially easy it is to switch presentation styles.

Complete Code

SheetsBootcamp.swift

swift
import SwiftUI

struct SheetsBootcamp: View {

    @State var showSheet: Bool = false // controls whether SecondScreen is presented
    
    var body: some View {
        ZStack {
            Color.green
                .edgesIgnoringSafeArea(.all)
            
            Button(action: {
                showSheet.toggle() // sets showSheet to true; SwiftUI presents the sheet
            }, label: {
                Text("Button")
                    .foregroundColor(.green)
                    .font(.headline)
                    .padding(20)
                    .background(Color.white.cornerRadius(10))
            })
//            .fullScreenCover(isPresented: $showSheet, content: { // swap this in to get a full-screen version
//                SecondScreen()
//            })
            .sheet(isPresented: $showSheet, content: {
                // DO NOT ADD CONDITIONAL LOGIC — the closure should return exactly one view
                SecondScreen()
            })
        }
    }
}

struct SecondScreen: View {
    
    @Environment(\.presentationMode) var presentationMode // access to this view's presentation context
    
    var body: some View {
        ZStack(alignment: .topLeading) {
            Color.red
                .edgesIgnoringSafeArea(.all)
            
            Button(action: {
                presentationMode.wrappedValue.dismiss() // tells SwiftUI to set showSheet = false in the parent
            }, label: {
                Image(systemName: "xmark")
                    .foregroundColor(.white)
                    .font(.largeTitle)
                    .padding(20)
            })
        }
    }
}


struct SheetsBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        SheetsBootcamp()
        //SecondScreen()
    }
}

Code Walkthrough

  1. @State var showSheet: Bool = false — The entire presentation lifecycle is controlled by this one Boolean. SwiftUI observes it; when it becomes true, the sheet appears. When it returns to false, the sheet dismisses.

  2. .sheet(isPresented: $showSheet, content: { SecondScreen() }) — This modifier is attached to the Button. The $showSheet passes a binding so SwiftUI can set it back to false when the user swipes the sheet down. The content closure returns SecondScreen().

  3. // DO NOT ADD CONDITIONAL LOGIC — This comment exists because a common mistake is writing if condition { ViewA() } else { ViewB() } inside the content closure. The content is captured once at presentation time, so conditional switching inside the closure doesn't work reliably.

  4. @Environment(\.presentationMode) var presentationMode — This is an environment value injected automatically into SecondScreen because it's being presented. It provides a handle to call dismiss().

  5. presentationMode.wrappedValue.dismiss() — The .wrappedValue is needed because presentationMode is wrapped in a Binding. In iOS 15+, you'd use @Environment(\.dismiss) var dismiss and call dismiss() directly — no .wrappedValue needed.

  6. The commented .fullScreenCover line — To switch from sheet to full screen, you uncomment this and comment out the .sheet below it. The SecondScreen content stays identical — the only change is the presentation style. This illustrates how SwiftUI separates presentation mechanism from content.

Common Mistakes

Mistake: Forgetting to pass $showSheet (using showSheet without $)
Without the $, SwiftUI gets the Boolean value at the moment of the modifier call, not a reference to it. The sheet will never be able to dismiss itself by setting the value back to false. Always use $ for isPresented.

Mistake: Adding complex conditional logic inside the sheet content closure
The content closure is evaluated once when the sheet is presented, not reactively. To show different content based on state, set up the state before presenting the sheet (e.g., set an enum value indicating which screen to show, then present).

Mistake: Using fullScreenCover where sheet is more appropriate
fullScreenCover hides the context the user was just in. For contextual actions (like editing a single item, sharing, or picking from a list), a sheet that shows the underlying context is usually the better UX. Reserve fullScreenCover for flows that completely replace the current context.

Key Takeaways

  • SwiftUI sheet and fullScreenCover presentation is state-driven — a Boolean controls whether the screen is shown or hidden.
  • @Environment(\.presentationMode) gives presented views the ability to dismiss themselves without needing a direct reference to the parent's state.
  • sheet shows the underlying view partially; fullScreenCover replaces the entire screen — choose based on whether context is useful to the user.

Last updated: June 27, 2026

Released under the MIT License.