Skip to content

Instantly share code, notes, and snippets.

@auramagi
Last active December 27, 2022 07:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save auramagi/776772783036bd7be1da0e8d8a8921a0 to your computer and use it in GitHub Desktop.
Save auramagi/776772783036bd7be1da0e8d8a8921a0 to your computer and use it in GitHub Desktop.
Global alert handler for SwiftUI

Global alert handler for SwiftUI

  • Fire-and-forget way of showing system alerts in SwiftUI
  • Will not fail if screen is already presenting a modal (🫤)
  • Dipping into UIKit for creating a custom alert UIWindow
  • Windowing logic adapted from https://www.fivestars.blog/articles/swiftui-windows/ (without going to the trouble of creating a custom Scene Delegate)
struct ContentView: View {
    @StateObject var errorService = ErrorStateService()

    var body: some View {
        NavigationStack {
            Button("Do a task") {
                do {
                    try runTask()
                } catch {
                    errorService.insert(.localizedError(title: nil, error: error))
                }
            }
        }
        .alertPresentationWindow(service: errorService)
    }
}
import SwiftUI
private struct AlertPresentationWindow: View {
@ObservedObject var service: ErrorStateService
var body: some View {
Color.clear
.alert(
service.alerts.first?.title ?? "Error",
isPresented: .init(
get: { service.alerts.first != nil },
set: { _ in service.remove(id: service.alerts.first?.id ?? "") }
),
presenting: service.alerts.first,
actions: { alert in
ForEach(alert.buttons, id: \.title) { button in
Button(button.title, role: button.isDestructive ? .destructive : nil, action: button.action)
}
},
message: { alert in Text(alert.message) }
)
}
}
private struct AlertPresentationWindowContext: ViewModifier {
let service: ErrorStateService
@State private var alertWindow: UIWindow?
func body(content: Content) -> some View {
content.onAppear {
guard alertWindow == nil else { return }
let windowScene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene}
.first { $0.windows.contains(where: \.isKeyWindow) }
guard let windowScene else { return assertionFailure("Could not get a UIWindowScene") }
let alertWindow = PassThroughWindow(windowScene: windowScene)
let alertViewController = UIHostingController(rootView: AlertPresentationWindow(service: service))
alertViewController.view.backgroundColor = .clear
alertWindow.rootViewController = alertViewController
alertWindow.isHidden = false
alertWindow.windowLevel = .alert
self.alertWindow = alertWindow
}
}
}
private final class PassThroughWindow: UIWindow {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let hitView = super.hitTest(point, with: event) else { return nil }
// If the returned view is the `UIHostingController`'s view, ignore.
return rootViewController?.view == hitView ? nil : hitView
}
}
extension View {
func alertPresentationWindow(service: ErrorStateService) -> some View {
modifier(AlertPresentationWindowContext(service: service))
}
}
import Foundation
public struct ErrorAlert: Identifiable {
public struct Button {
public let title: String
public let isDestructive: Bool
public let action: () -> Void
public init(title: String, isDestructive: Bool, action: @escaping () -> Void) {
self.title = title
self.isDestructive = isDestructive
self.action = action
}
}
public let id: String
public let title: String?
public let message: String
public let underlying: Error?
public let buttons: [Button]
}
public extension ErrorAlert {
static func localizedError(
id: String = UUID().uuidString,
title: String?,
error: Error,
buttons: [Button] = []
) -> Self {
.init(
id: id,
title: title,
message: error.localizedDescription,
underlying: error,
buttons: buttons
)
}
static func customMessageLocalizedError(
id: String = UUID().uuidString,
title: String?,
message: String,
error: Error,
buttons: [Button] = []
) -> Self {
.init(
id: id,
title: title,
message: "\(message)\n\n\(error.localizedDescription)",
underlying: error,
buttons: buttons
)
}
static func customMessage(
id: String = UUID().uuidString,
title: String?,
message: String,
underlying: Error?,
buttons: [Button] = []
) -> Self {
.init(
id: id,
title: title,
message: message,
underlying: underlying,
buttons: buttons
)
}
}
import Combine
import Foundation
public final class ErrorStateService: ObservableObject {
@Published public private(set) var alerts: [ErrorAlert] = []
public init() { }
public func insert(_ alert: ErrorAlert) {
remove(id: alert.id)
alerts.append(alert)
}
public func remove(id: ErrorAlert.ID) {
alerts.removeAll { $0.id == id }
}
public func hasAlert(id: ErrorAlert.ID) -> Bool {
alerts.contains { $0.id == id }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment