A sheet is the phone's answer to a dialog, and SwiftUI's version of it is one of the places the framework has genuinely less code than the alternatives. It is also where a specific, avoidable bug shows up constantly: a tap that appears to do nothing.
This lesson is the presentation APIs, the difference between the two that matters most, detents, alerts, and the shared chrome that stops every sheet in an app looking slightly different.
Two presentation APIs
The one everyone learns first takes a boolean:
.sheet(isPresented: $isShowingCart) { CartSheetView() }
The other takes an optional value:
.sheet(item: $productBeingBuilt) { product in
PizzaBuilderSheet(product: product) { productBeingBuilt = nil }
.presentationDetents([.large])
.presentationDragIndicator(.hidden)
}
Non-nil presents, nil dismisses, and the closure receives the unwrapped value. It
is strictly better than the boolean whenever the sheet is about something, and the reason
is not convenience.
Why item: resets state for free
.sheet(item:) builds a fresh view for each distinct value. Open a
different pizza and it is a different view — new identity, new @State, so every
selection starts from that product's defaults with no code at all.
With a boolean you get one sheet whose state persists between presentations. The customer configures a large stuffed-crust pepperoni, closes it, opens a margherita, and finds the previous pizza's toppings. The usual fix is an effect that copies the new product into state when the flag flips — which renders the old values for a frame first, and is the exact pattern the React Native version of this app needs a bumped counter and a paragraph of comment to avoid. Here, choosing the right modifier is the whole solution.
The boolean trap
The related bug is having several booleans. Three @State private var isXPresented
flags let two sheets be "presented" at once, and SwiftUI resolves that by showing one and silently
ignoring the other. It reads as a dropped tap, and it is intermittent, because it depends on which
flag was set last.
One optional enum makes it unrepresentable:
enum AppSheet: Identifiable, Hashable {
case cart
case signIn
case register
var id: Self { self }
}
Now "which sheet is open" has exactly one answer at all times, and presenting a second one replaces the first rather than racing it.
When the value is not naturally Identifiable
The profile screen's form sheet either adds an address or edits one. A bare
Address? cannot express that, because nil already means "closed":
private enum AddressFormMode: Identifiable {
case add
case edit(Address)
var id: String {
switch self {
case .add: "new"
case let .edit(address): address.id
}
}
Both states get a spelling, and because the ids differ, switching from adding to editing rebuilds the sheet and resets the form — the same identity mechanism, used deliberately.
Detents
A sheet does not have to be full height. Detents are the resting positions it can snap to:
CartSheetView()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
Two entries means the customer can drag between half height and full. A cart with one line does not need the whole screen, and the smaller detent leaves the menu visible behind it — which keeps the customer oriented in a way a full-screen modal does not.
Beyond .medium and .large there are .height(300) and
.fraction(0.3) for something specific. Related modifiers worth knowing:
.presentationBackgroundInteraction(.enabled) lets the customer interact with what is
behind a half-height sheet, and .interactiveDismissDisabled() stops a swipe-to-dismiss
— appropriate for a form with unsaved changes, and irritating anywhere else.
Shared chrome
SwiftUI gives you the presentation, the drag gesture and the animation. What it does not give you is the inside: a title that stays put while content scrolls under it, and a footer pinned above the safe area. Left to each screen, those drift apart.
struct SheetScaffold<Content: View, Footer: View>: View {
let title: String
let onClose: () -> Void
@ViewBuilder let content: () -> Content
@ViewBuilder let footer: () -> Footer
Two @ViewBuilder closures — a body and a footer — so a caller fills both and gets the
grabber, the title row and the close button without asking. The builder sheet puts its live price and
"Add to cart" in the footer; the cart puts its totals and checkout button there.
A convenience initialiser covers the sheets with no footer:
extension SheetScaffold where Footer == EmptyView {
init(
title: String,
onClose: @escaping () -> Void,
@ViewBuilder content: @escaping () -> Content
) {
self.init(title: title, onClose: onClose, content: content) { EmptyView() }
}
}
Constraining an extension on the generic parameter is a very Swift thing to do, and this is the common case for it: the second closure only exists when the caller wants it.
Inside, the layout is three regions — a fixed header, a scrolling middle, and a footer that only appears if the caller supplied one:
HStack {
Text(title)
.textStyle(.heading)
.accessibilityAddTraits(.isHeader)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: FontSize.md, weight: .semibold))
.foregroundStyle(Theme.colors.textMuted)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
.contentShape(Rectangle()) is the modifier people miss. Without it only the drawn
glyph is tappable, so the 44-point frame is a lie — it looks like a large target and behaves like a
small one. It tells SwiftUI to treat the whole frame as hit-testable, and it is worth reaching for
any time a tappable area is mostly empty space.
The close button is not optional
Swipe-to-dismiss exists, and it is not discoverable enough to be the only way out. An explicit close control matters more for accessibility than for convenience — a customer using VoiceOver or Switch Control cannot swipe a sheet away. The scaffold gives it a 44-point tappable frame and an accessibility label, and hides the drag indicator because the grabber is drawn as part of the header instead.
Dismiss before you navigate
A sheet still presented over a pushed screen swallows every tap on it. So the checkout button in the cart does these two things in this order:
router.dismissSheet()
router.push(.checkout)
Both happen in the same run loop pass, so there is no visible gap — but reversing them gives you a checkout screen that appears and then ignores you. This is worth remembering as a general rule: finish with the modal layer before moving the layer underneath it.
Alerts and confirmations
A sheet is for a task. An alert is for a question, and specifically for one the customer must answer before anything else happens.
.alert(item: $pendingDeletion) { deletion in
Alert(
title: Text(deletion.title),
message: Text(deletion.message),
primaryButton: .destructive(Text("Delete")) {
Task { report(await deletion.confirm()) }
},
secondaryButton: .cancel()
)
}
Three decisions in that snippet. It is the system's alert rather than a custom
sheet, because for "are you sure you want to delete this" matching the platform is what makes it
read as serious. The destructive button is marked .destructive, which colours it red
and tells assistive technology what it is. And it uses item: again, so two rapid taps
cannot stack two dialogs.
The value being presented carries the action itself:
private struct PendingDeletion: Identifiable {
let id = UUID()
let title: String
let message: String
let confirm: () async -> ActionOutcome
}
One alert handles every deletion on the screen. The row that raised it supplies the copy and the closure; the alert does not know whether it is deleting an address or a card.
Do not use an alert for confirmation of success
"Added to your cart" must not interrupt. An alert steals focus, blocks the screen and demands a tap to dismiss — appropriate for a question, hostile for a confirmation nobody asked for. That is what a toast is for, and the demo app presents them as an overlay at the root so a message fired from inside a sheet is still visible above it.
For a menu of destructive choices, confirmationDialog is the right control rather
than an alert with four buttons — it is the action sheet that slides up from the bottom, and it is
what iOS users expect from "more options".
The keyboard inside a sheet
A sheet with a form has a problem the rest of the app does not: the keyboard covers the bottom of a container that is already only part of the screen. SwiftUI handles most of it — the sheet's content is inset for the keyboard automatically — but two things still need doing.
The scrolling region needs .scrollDismissesKeyboard(.interactively), or the customer
has no way to get the keyboard out of the way of the footer button. And a form sheet wants
.presentationDetents([.large]) rather than a medium one, because a half-height sheet
that grows to fit the keyboard and then shrinks again is visually chaotic. The address form in this
app takes the large detent for exactly that reason.
Previewing a sheet
Sheets are awkward in previews because a preview renders a view, not a presentation. The idiom is to present from an empty view:
#Preview {
let environment = AppEnvironment.preview()
return Color.clear.sheet(isPresented: .constant(true)) {
CartSheetView()
.environment(environment.cart)
.environment(AppRouter())
}
}
.constant(true) is a binding that cannot change, which is exactly right here — the
preview never dismisses. This gets you the real detents and the real presentation chrome rather than
the sheet's contents floating in a rectangle, and it is the difference between a preview that shows
the design and one that shows a component.
Full-screen covers
fullScreenCover is the sheet's heavier sibling: no card, no swipe to dismiss, the
whole screen. It is right for onboarding, a camera, a media player — anything where the app behind
genuinely should not be visible or reachable.
It is wrong for most things. Sign-in in this app is a sheet precisely because ordering never requires an account, and the card-over-the-app presentation is the platform's way of saying "this is a detour, you can leave". A full-screen cover would imply the customer has to get through it.
What a sheet should not be
Two habits worth resisting.
A sheet inside a sheet. It works — iOS stacks them — and it almost always means the first sheet should have been a pushed screen. Two cards deep, the customer has lost track of what dismissing will return them to, and the swipe gesture becomes ambiguous.
A sheet as a router. If a modal's job is to offer three destinations, those are navigation, and the tab bar or a pushed list expresses it better. A modal is for finishing something and coming back — the test is whether the customer returns to where they were, and whether that is the point.
Next
Every component so far — the card, the field, the scaffold — has quietly used tokens and a theme without explanation. The next lesson is that layer: tokens, a semantic tier over them, and components that consume the semantic tier, plus why skipping the middle one makes a retune impossible.