Skip to content

Instantly share code, notes, and snippets.

@phillipcaudell
Created September 1, 2023 09:50
Show Gist options
  • Save phillipcaudell/ecb32dc5a3915931d225a6cb8a67ac34 to your computer and use it in GitHub Desktop.
Save phillipcaudell/ecb32dc5a3915931d225a6cb8a67ac34 to your computer and use it in GitHub Desktop.
Present a confirmation dialogue from a swipeAction, contextMenu or other transient view.
import SwiftUI
/// Describe a potentially destructive action to the user.
///
/// A confirmation describes the action to the user and provides a mechanism for confirming the action in the
/// form of a `Button`. You present a confirmation by calling the environment's confirm action:
///
/// ```swift
/// @Environment(\.confirm) private var confirm
///
/// var body: some View {
/// Button {
/// confirm(deletion)
/// } label: {
/// Label("Delete", systemImage: "trash")
/// }
/// .tint(.red)
/// }
///
/// var deletion: Confirmation {
/// Confirmation(
/// "Are you sure you want to delete?",
/// titleVisibility: .visible
/// ) {
/// Button(role: .destructive) {
/// delete()
/// } label: {
/// Text("Delete Forever")
/// }
/// }
/// }
/// ```
///
/// You can also call confirm directly without the need for a confirmation instance:
///
/// ```swift
/// @Environment(\.confirm) private var confirm
///
/// var body: some View {
/// Button {
/// confirm("Are you sure?") {
/// Button(role: .destructive) {
/// delete()
/// } label: {
/// Text("Delete Forever")
/// }
/// }
/// } label: {
/// Label("Delete", systemImage: "trash")
/// }
/// }
/// ```
///
/// You must install a `confirmationDialogue()` somewhere higher up in the view hierarchy that will
/// remain stable, such as a `List` or `NavigationStack`.
///
/// ```swift
/// List {
/// ...
/// }
/// .confirmationDialog()
///
public struct Confirmation {
let id = UUID()
let title: LocalizedStringKey
let titleVisibility: Visibility
// A little bit of type-erasure never hurt anybody
let message: AnyView
let actions: AnyView
}
extension Confirmation {
/// Creates a new confirmation instance.
public init<Actions, Message>(
_ title: LocalizedStringKey,
titleVisibility: Visibility = .automatic,
@ViewBuilder message: @escaping () -> Message,
@ViewBuilder actions: @escaping () -> Actions
) where Actions: View, Message: View {
self.title = title
self.titleVisibility = titleVisibility
self.actions = AnyView(actions())
self.message = AnyView(message())
}
/// Creates a new confirmation instance.
public init<Actions>(
_ title: LocalizedStringKey,
titleVisibility: Visibility = .automatic,
@ViewBuilder actions: @escaping () -> Actions
) where Actions: View {
self.init(title, titleVisibility: titleVisibility) {
EmptyView()
} actions: {
actions()
}
}
}
/// Handles confirmations in the environment.
public struct ConfirmationAction {
let handler: (Confirmation) -> Void
public func callAsFunction(_ confirmation: Confirmation) {
handler(confirmation)
}
public func callAsFunction<Actions>(
_ title: LocalizedStringKey,
titleVisibility: Visibility = .automatic,
@ViewBuilder actions: @escaping () -> Actions
) where Actions: View {
handler(
Confirmation(
title,
titleVisibility: titleVisibility,
actions: actions
)
)
}
}
struct ConfirmationDialogModifier: ViewModifier {
@State private var isPresented = false
@State private var confirmation: Confirmation?
func body(content: Content) -> some View {
content
.confirmationDialog(
confirmation?.title ?? "",
isPresented: $isPresented,
titleVisibility: confirmation?.titleVisibility ?? .automatic
) {
if let confirmation {
confirmation.actions
}
} message: {
if let confirmation {
confirmation.message
}
}
.environment(\.confirm, confirmAction)
}
var confirmAction: ConfirmationAction {
.init {
confirmation = $0
isPresented = true
}
}
}
extension EnvironmentValues {
struct ConfirmationKey: EnvironmentKey {
static var defaultValue = ConfirmationAction { _ in }
}
/// Present a confirmation to the user.
public var confirm: ConfirmationAction {
get { self[ConfirmationKey.self] }
set { self[ConfirmationKey.self] = newValue }
}
}
extension View {
/// Installs a confirmation dialogue in the view.
public func confirmationDialog() -> some View {
self.modifier(ConfirmationDialogModifier())
}
}
@phillipcaudell
Copy link
Author

phillipcaudell commented Sep 1, 2023

If you present a confirmation dialogue from a swipeAction or contextMenu nothing will happen as the view is removed before the dialogue is presented. Annoying, but it makes perfect sense.

Instead, delegate the confirmation to the environment. Now the Button isn't responsible for the presentation and you're left with a nice clean call site.

Here's a complete example:

struct ConfirmationExample: View {
    
    @State private var items: [Int] = (0...100).map { $0 }
    
    var body: some View {
        NavigationStack {
            List{
                ForEach(items, id: \.self) { id in
                    Text("Item \(id)")
                        .swipeActions {
                            DeleteButton(item: id) {
                                items.remove(at: $0)
                            }
                        }
                }
            }
            .confirmationDialog()
            .animation(.default, value: items)
        }
    }
}

extension ConfirmationExample {
    
    struct DeleteButton: View {
        
        let item: Int
        let onDelete: (Int) -> Void
        
        @Environment(\.confirm) private var confirm
        
        var body: some View {
            Button {
                confirm("Are you sure?") {
                    Button(role: .destructive) {
                        delete()
                    } label: {
                        Text("Delete Forever")
                    }
                }
            } label: {
                Label("Delete", systemImage: "trash")
            }
            .tint(.red)
        }
        
        func delete() {
            onDelete(item)
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment