Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 022 / 100

Sheets and alerts in SwiftUI in Swift, SwiftUI & iOS Apps

Present content modally.

Expona Admin9 min read

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:

swift
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 selectedContact becomes 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:

swift
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:

swift
@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:

swift
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
  • .medium is roughly 50% of screen height; .large is 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:

swift
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:

swift
@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
  • .alert is for binary decisions; .confirmationDialog for multi-option choices
  • Presentation detents let you build modern half-sheet UIs; expose .medium and .large options 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
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles