How to implement an action sheet in SwiftUI
Attach .confirmationDialog(_:isPresented:titleVisibility:actions:) to any view, drive it with a @State Bool, and place Button values (including destructive ones) inside the trailing closure. The old ActionSheet type is deprecated — confirmationDialog is the modern, accessible replacement.
struct ContentView: View {
@State private var showSheet = false
var body: some View {
Button("Show Options") {
showSheet = true
}
.confirmationDialog("What would you like to do?",
isPresented: $showSheet,
titleVisibility: .visible) {
Button("Save") { }
Button("Share") { }
Button("Delete", role: .destructive) { }
Button("Cancel", role: .cancel) { }
}
}
}
Full implementation
The example below models a realistic photo management scenario: selecting a photo triggers a sheet offering Save, Share, and Delete options. A separate piece of @State captures which action the user chose so you can react to it in your view model or elsewhere. The message parameter adds a subtitle beneath the dialog title for extra context when needed.
import SwiftUI
struct Photo: Identifiable {
let id = UUID()
let title: String
}
enum PhotoAction {
case save, share, delete
}
struct PhotoRowView: View {
let photo: Photo
@State private var showActions = false
@State private var lastAction: PhotoAction?
var body: some View {
HStack {
Image(systemName: "photo.fill")
.foregroundStyle(.secondary)
.frame(width: 44, height: 44)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(photo.title)
.font(.body)
if let action = lastAction {
Text("Last: \(String(describing: action))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button {
showActions = true
} label: {
Image(systemName: "ellipsis.circle")
.imageScale(.large)
.accessibilityLabel("More options for \(photo.title)")
}
.buttonStyle(.plain)
}
.padding(.vertical, 4)
.confirmationDialog(
"Photo Options",
isPresented: $showActions,
titleVisibility: .visible
) {
Button("Save to Library") {
lastAction = .save
}
Button("Share…") {
lastAction = .share
}
Button("Delete Photo", role: .destructive) {
lastAction = .delete
}
Button("Cancel", role: .cancel) { }
} message: {
Text("Choose an action for \"\(photo.title)\"")
}
}
}
struct PhotoListView: View {
private let photos = [
Photo(title: "Sunset at Big Sur"),
Photo(title: "Morning Coffee"),
Photo(title: "Weekend Hike"),
]
var body: some View {
NavigationStack {
List(photos) { photo in
PhotoRowView(photo: photo)
}
.navigationTitle("Photos")
}
}
}
#Preview {
PhotoListView()
}
How it works
-
@State private var showActions = false— A private Boolean drives the entire presentation lifecycle. Setting it totruepresents the sheet; SwiftUI resets it tofalseautomatically when the user dismisses the dialog (including tapping Cancel or the system dismiss gesture). -
.confirmationDialog(_:isPresented:titleVisibility:)— The modifier is attached directly to the view that logically owns the action, which keeps state and UI co-located.titleVisibility: .visibleensures the title renders above the buttons; pass.hiddenif you only want the buttons shown. -
Button roles —
role: .destructivecolours the Delete button red automatically and VoiceOver announces it as destructive — no manual styling required.role: .cancelalways anchors to the bottom of the sheet as a separate button, matching iOS HIG conventions. -
messageclosure — The optional trailingmessageclosure renders a subtitle line beneath the title. Use it to provide context (e.g., the specific item name) so users feel confident before tapping a destructive action. -
lastActionstate — Capturing the selected action into@State(or forwarding it to a view model via a closure or@Binding) decouples your business logic from the presentation layer cleanly.
Variants
Triggered by a context menu long-press
You can combine contextMenu with confirmationDialog so a long-press reveals quick actions while a destructive path still gets confirmation.
struct ItemView: View {
let title: String
@State private var confirmDelete = false
var body: some View {
Text(title)
.contextMenu {
Button("Edit") { }
Button("Duplicate") { }
Divider()
Button("Delete…", role: .destructive) {
confirmDelete = true
}
}
.confirmationDialog(
"Delete \"\(title)\"?",
isPresented: $confirmDelete,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
// perform deletion
}
Button("Cancel", role: .cancel) { }
} message: {
Text("This action cannot be undone.")
}
}
}
Dynamically built button list
When the available actions depend on runtime data (e.g., user permissions or item state), build buttons inside the closure using a ForEach over an array of action descriptors. The confirmationDialog actions closure is a @ViewBuilder, so standard control-flow constructs like if, ForEach, and Group all work inside it. Just keep total button count to seven or fewer to avoid the sheet becoming unwieldy — consider a Menu instead for longer lists.
Common pitfalls
-
Using the deprecated
ActionSheetstruct.ActionSheetwas soft-deprecated in iOS 14 and removed from the recommended path in iOS 16. If you see.sheetdriven by anActionSheetvalue in old tutorials, replace it with.confirmationDialog. The compiler still accepts the old API but will warn in Xcode 16. -
Forgetting
Button("Cancel", role: .cancel)on iPad. On iPhone the system adds a Cancel button automatically at the bottom of the sheet. On iPad,confirmationDialogrenders as a popover anchored to the source view — there is no system-injected Cancel button, so omitting it leaves users without a clear dismiss path. Always include it explicitly. -
Accessibility labels on the trigger button. If your trigger is an icon button (e.g.,
ellipsis.circle), add a descriptive.accessibilityLabelthat includes the item name — for example"More options for Sunset at Big Sur"— so VoiceOver users understand the context before activating the sheet.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement an action sheet in SwiftUI for iOS 17+. Use confirmationDialog with titleVisibility: .visible. Include Save, Share, and a destructive Delete option. Make it accessible (VoiceOver labels on the trigger button). Add a #Preview with realistic sample data.
Paste this prompt into Soarias during the Build phase after you've locked your screen flow — the generated component slots directly into your navigation stack without extra scaffolding.
Related
FAQ
Does this work on iOS 16?
Yes — confirmationDialog was introduced in iOS 15, so it works on iOS 15, 16, and 17+. This guide targets iOS 17+ because it uses Swift 5.10 syntax and the #Preview macro (Xcode 15+), but the confirmationDialog modifier itself requires no iOS 17-specific changes. If you need iOS 14 support, you can conditionally fall back to ActionSheet, but that API is deprecated and should be avoided in new code.
Can I have more than one confirmationDialog on the same view?
Yes, but each needs its own @State Bool binding. Only one can be presented at a time — triggering a second while the first is visible has no visible effect until the first is dismissed. A common pattern is to use a single @State enum (e.g., enum ActiveSheet) to represent which dialog to show, then switch over it in a single confirmationDialog modifier to keep your view tree clean.
What's the UIKit equivalent?
confirmationDialog maps directly to UIAlertController with preferredStyle: .actionSheet in UIKit. The SwiftUI modifier handles all the iPad/iPhone distinction for you (popover vs. sheet), whereas in UIKit you have to manually set popoverPresentationController.sourceView on iPad — a common source of crashes. confirmationDialog is strictly simpler.
Last reviewed: 2026-05-11 by the Soarias team.