Sheets and alerts in SwiftUI in Swift, SwiftUI & iOS Apps
Modal presentation is one of the most common UI patterns you'll implement, whether you're collecting user input, confirming destructive actions, or displaying additional information. SwiftUI provides several powerful ways to present content modally—sheets, alerts, and confirmation dialogs—each suited to different scenarios. In this article, we'll walk through the patterns that work best in production apps, from simple alerts to data-driven sheets with custom sizing.
Understanding modal presentation in SwiftUI
Before diving into specific modifiers, let's establish why modal presentation matters. A modal view interrupts the user's current workflow and demands attention. Unlike navigation, which preserves a navigation stack, modal presentation is typically temporary: the user completes an action and dismisses the view to return to where they started.
SwiftUI gives us three main tools for modal presentation:
- Sheets: Full or partial-height views that slide up from the bottom
- Alerts: Simple modal dialogs for critical information or confirmation
- Confirmation dialogs: Multi-option dialogs (replaced some alert use cases in iOS 15+)
The key to clean modal code is managing presentation state correctly—whether that's a boolean flag, an optional model, or a more complex state machine in your view model.
.sheet(item:) for data-driven sheets
The most powerful sheet pattern uses .sheet(item:), which ties sheet presentation directly to a piece of data. This is cleaner and more testable than managing boolean flags.
Consider a contacts app where tapping a contact opens an edit sheet. Rather than a separate @State boolean, we store the selected contact:
struct ContactListView: View {
@StateObject private var viewModel = ContactViewModel()
@State private var selectedContact: Contact?
var body: some View {
List(viewModel.contacts) { contact in
Button(action: { selectedContact = contact }) {
VStack(alignment: .leading) {
Text(contact.name)
Text(contact.email)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.sheet(item: $selectedContact) { contact in
EditContactSheet(contact: contact) { updated in
viewModel.update(updated)
selectedContact = nil
}
}
}
}
struct EditContactSheet: View {
let contact: Contact
let onSave: (Contact) -> Void
@Environment(\.dismiss) var dismiss
@State private var name = ""
@State private var email = ""
var body: some View {
NavigationStack {
Form {
TextField("Name", text: $name)
TextField("Email", text: $email)
}
.navigationTitle("Edit Contact")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(Contact(id: contact.id, name: name, email: email))
dismiss()
}
}
}
}
.onAppear {
name = contact.name
email = contact.email
}
}
}This approach has several advantages:
- The sheet state is tied to actual data, not a boolean flag
- SwiftUI automatically dismisses the sheet when
selectedContactbecomes nil - Testing is straightforward: set the state to a test contact and verify behavior
- You avoid the common bug where a dismissed sheet tries to update state
The key is that item parameter must conform to Identifiable. If your model already has an id, you're set. If not, you can add conformance or wrap the value in a tuple with an id.
.alert and .confirmationDialog for user decisions
Alerts are for critical, high-priority messages. Use them sparingly—if you're showing three alerts per user session, reconsider the UX.
Pre-iOS 15, .alert handled both simple confirmations and multi-option dialogs. Modern code uses .alert for two-button scenarios and .confirmationDialog for three or more options.
Here's a practical example: deleting a contact requires confirmation, and we want the delete button styled as destructive:
struct ContactDetailView: View {
let contact: Contact
@StateObject private var viewModel = ContactViewModel()
@Environment(\.dismiss) var dismiss
@State private var showDeleteAlert = false
var body: some View {
VStack {
Text(contact.name)
.font(.title)
Text(contact.email)
Spacer()
}
.toolbar {
ToolbarItem(placement: .destructiveAction) {
Button("Delete", action: { showDeleteAlert = true })
}
}
.alert("Delete Contact", isPresented: $showDeleteAlert) {
Button("Cancel", role: .cancel) { }
Button("Delete", role: .destructive) {
viewModel.delete(contact)
dismiss()
}
} message: {
Text("Are you sure? This action cannot be undone.")
}
}
}Notice we use role: .destructive on the delete button. SwiftUI styles this appropriately (red on iOS), signaling danger to the user.
For more complex choices—like selecting an export format—use .confirmationDialog:
@State private var exportFormat: ExportFormat?
var body: some View {
Button("Export") { exportFormat = .choosingFormat }
.confirmationDialog("Export Format", isPresented: $showExportDialog) {
Button("CSV") { exportFormat = .csv }
Button("JSON") { exportFormat = .json }
Button("PDF") { exportFormat = .pdf }
Button("Cancel", role: .cancel) { }
}
}The difference: alerts are blocking and modal, while confirmation dialogs feel more integrated into the UI on modern iOS.
Presentation detents for half sheets and custom sizes
iOS 16 introduced presentation detents, a game-changer for partially-visible sheets. Instead of full-screen sheets, you can show a sheet at 25%, 50%, or a custom fraction of the screen.
This is especially useful for filters, quick-action menus, or input forms that don't need the full screen:
struct ProductListView: View {
@State private var showFilters = false
@StateObject private var viewModel = ProductViewModel()
var body: some View {
ZStack {
List(viewModel.products) { product in
ProductRow(product: product)
}
VStack {
Spacer()
Button(action: { showFilters = true }) {
Label("Filter", systemImage: "slider.horizontal.3")
.frame(maxWidth: .infinity)
.padding()
.background(.blue)
.foregroundColor(.white)
.cornerRadius(8)
.padding()
}
}
}
.sheet(isPresented: $showFilters) {
FilterSheet(onApply: { filters in
viewModel.apply(filters)
showFilters = false
})
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
}
struct FilterSheet: View {
let onApply: ([String: String]) -> Void
@Environment(\.dismiss) var dismiss
@State private var selectedCategory = ""
@State private var priceRange = 0.0
var body: some View {
NavigationStack {
Form {
Picker("Category", selection: $selectedCategory) {
Text("All").tag("")
Text("Electronics").tag("electronics")
Text("Clothing").tag("clothing")
}
Slider(value: $priceRange, in: 0...1000, step: 10)
.onChange(of: priceRange) { _ in }
}
.navigationTitle("Filter")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply") {
onApply(["category": selectedCategory, "price": String(priceRange)])
dismiss()
}
}
}
}
}
}Key points on detents:
.presentationDetents([.medium, .large])allows users to drag between sizes.mediumis roughly 50% of screen height;.largeis full screen.presentationDragIndicator(.visible)shows a draggable handle at the top- You can use
.fraction(0.4)or.height(400)for custom sizes
Half sheets feel more native to modern iOS and keep users aware of the underlying context.
Keep presentation state in the view or model
Where should you store sheet and alert state? The answer depends on complexity.
Simple case: View state
For straightforward sheets, @State in the view is fine:
struct MyView: View {
@State private var showSettings = false
var body: some View {
Button("Settings") { showSettings = true }
.sheet(isPresented: $showSettings) {
SettingsSheet()
}
}
}Complex case: ViewModel state
If sheet presentation logic depends on business logic, move it to your view model:
@MainActor
class OrderViewModel: ObservableObject {
@Published var showConfirmation = false
@Published var selectedPaymentMethod: PaymentMethod?
@Published var error: String?
func checkout() {
// Complex validation and preparation
if isValid {
showConfirmation = true
} else {
error = "Please fill in all fields"
}
}
func confirmPayment() async {
showConfirmation = false
do {
try await submitOrder()
} catch {
self.error = error.localizedDescription
}
}
}
struct CheckoutView: View {
@StateObject private var viewModel = OrderViewModel()
var body: some View {
VStack {
// Checkout form
}
.sheet(isPresented: $viewModel.showConfirmation) {
PaymentConfirmationSheet()
.environmentObject(viewModel)
}
.alert("Error", isPresented: .constant(viewModel.error != nil)) {
Button("OK") { viewModel.error = nil }
} message: {
Text(viewModel.error ?? "")
}
}
}This pattern keeps presentation logic testable and separates concerns.
Common mistakes to avoid
Mistake 1: Using boolean flags for optional data
Wrong: A show/hide boolean plus a separate optional value. This decouples state and can leave you in inconsistent states.
Right: Use .sheet(item:) so the sheet only appears when data exists.
Mistake 2: Not dismissing sheets properly
Sheets don't automatically dismiss when their view model reloads or network requests complete. Always explicitly dismiss via @Environment(\.dismiss) or by clearing your state variable.
Mistake 3: Calling onDisappear to clean up
Don't use onDisappear on sheet content to reset state. Instead, reset state in the parent after dismissal or use onDismiss closure if available. SwiftUI's lifecycle timing can be unpredictable.
Mistake 4: Nesting too many sheets
Deep sheet hierarchies (sheet opens another sheet) are confusing. If you need multi-step flows, consider a NavigationStack inside the sheet or a single-sheet state machine.
Mistake 5: Ignoring keyboard dismissal
If your sheet contains a TextField, test that the keyboard dismisses when you tap outside the field or explicitly call .dismissKeyboard() before dismissing the sheet on older iOS versions.
Key takeaways
- Use
.sheet(item:)for data-driven modals; it's cleaner and safer than boolean flags .alertis for binary decisions;.confirmationDialogfor multi-option choices- Presentation detents let you build modern half-sheet UIs; expose
.mediumand.largeoptions when sensible - Keep simple presentation state in
@State; complex logic belongs in your view model - Always dismiss sheets explicitly via
@Environment(\.dismiss)or by clearing state - Test modal flows thoroughly—edge cases like rapid taps or network delays can expose state management bugs






Comments
Loading comments…