Skip to content

Instantly share code, notes, and snippets.

@importRyan
Created March 2, 2021 22:55
Show Gist options
  • Save importRyan/8229e66c6c17eabd89705ec84028a3a9 to your computer and use it in GitHub Desktop.
Save importRyan/8229e66c6c17eabd89705ec84028a3a9 to your computer and use it in GitHub Desktop.
Alerts for SwiftUI in macOS

Alerts for SwiftUI in macOS

Only one alert may be attached to a view. To avoid collisions and clutter, one could handle alerts centrally using the func alert<Item>(item: Binding<Item?>... modifier on a senior view.

The init above accepts a binding for any identifiable object and provides a closure to map it into multiple types of Alert views. To minimize code in that closure and simplify call sites, below is a minimalist example wrapper and implementation. This isn’t complex at all, but while the API is decent it could be simpler.

Presentation

@main
struct SomeApp: App {
    @StateObject var coordinator: AppCoordinator
    
    var body: some Scene {
        WindowGroup() {
            MainWindow()
                .alert(item: coordinator.alert) { $0.present() }
        }
}
class AppCoordinator: ObservableObject {
    var alert: Binding<AlertItem?> {
        Binding<AlertItem?> { self.root.state.alert }
            set: { [weak self] value in self?.root.dispatch(.setAlert(value))  }
    }
}

When an alert is dismissed, SwiftUI sets the provided binding’s value to nil. Your buttons don’t need to clean up alert presentation state.

Model (wrap/map Alert views)

public struct RootState {
    var alert: AlertItem?
}
public struct AlertItem: Identifiable {
    public var id = UUID()
    public var title: Text
    public var message: Text?
    public var dismiss: Alert.Button?
    public var primary: Alert.Button?
    public var secondary: Alert.Button?
}

public extension AlertItem {
    init(title: String,
         message: String? = nil,
         dismiss: Alert.Button? = nil) {
        self.title = Text(title)
        self.message = message == nil ? nil : Text(message!)
        self.dismiss = dismiss
    }
    
    init(title: String,
         message: String? = nil,
         primary: Alert.Button? = nil,
         secondary: Alert.Button? = nil) {
        self.title = Text(title)
        self.message = message == nil ? nil : Text(message!)
        self.primary = primary
        self.secondary = secondary
    }
    
    func present() -> Alert {
        guard let primary = primary,
              let secondary = secondary else {
            return Alert(title: title,
                         message: message,
                         dismiss: dismiss)
        }
        return Alert(title: title,
                     message: message,
                     primary: primary,
                     secondary: secondary)
    }
}

Presenting view model

class FileMenuVM: VM {
    
    func save() {
		  // If needs alert
        root.dispatch(.setAlert(overwriteAlert))
    }
    
    lazy var overwriteAlert = AlertItem(title: Strings.Alerts.overwriteWarning,
                                        message: nil,
                                        primary: overwritePrimary,
                                        secondary: overwriteSecondary)
    
    lazy var overwritePrimary: Alert.Button = .default(Text(Strings.Alerts.overwriteWarningPrimary)) {
        // Actions
    }
    
    lazy var overwriteSecondary: Alert.Button = .destructive(Text(Strings.Alerts.overwriteWarningDestructive)) {
        // Actions
    }

    lazy var otherInitExample = AlertItem(title: Strings.Alerts.someAlert,
                                          message: nil,
                                          dismiss: nil)
}
@importRyan
Copy link
Author

Be aware that in macOS Big Sur in dark mode the destructive button on alerts is an illegible salmon-on-gray, with a WCAG contrast ratio of 1.7. Stick with AppKit. Feedback filed: FB9026454

Destructive

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