How to display an Error Alert in SwiftUI?

3.9k views Asked by At

Setup:

I have a SwiftUI View that can present alerts. The alerts are provided by an AlertManager singleton by setting title and/or message of its published property @Published var nextAlertMessage = ErrorMessage(title: nil, message: nil). The View has a property @State private var presentingAlert = false.

This works when the following modifiers are applied to the View:

.onAppear() {
    if !(alertManager.nextAlertMessage.title == nil && alertManager.nextAlertMessage.message == nil) {
        presentingAlert = true
    }
}
.onChange(of: alertManager.nextAlertMessage) { alertMessage in
    presentingAlert = alertMessage.title != nil || alertMessage.message != nil
}
.alert(alertManager.nextAlertMessage.joinedTitle, isPresented: $presentingAlert) {
    Button("OK", role: .cancel) {
        alertManager.alertConfirmed()
    }
}  

Problem:

Since alerts are also to be presented in other views, I wrote the following custom view modifier:

struct ShowAlert: ViewModifier {
    
    @Binding var presentingAlert: Bool
    let alertManager = AlertManager.shared
    
    func body(content: Content) -> some View {
        return content
            .onAppear() {
                if !(alertManager.nextAlertMessage.title == nil && alertManager.nextAlertMessage.message == nil) {
                    presentingAlert = true
                }
            }
            .onChange(of: alertManager.nextAlertMessage) { alertMessage in
                presentingAlert = alertMessage.title != nil || alertMessage.message != nil
            }
            .alert(alertManager.nextAlertMessage.joinedTitle, isPresented: $presentingAlert) {
                Button("OK", role: .cancel) {
                    alertManager.alertConfirmed()
                }
            }
    }
}  

and applied it to the View as:

.modifier(ShowAlert(presentingAlert: $presentingAlert))  

However, no alerts are now shown.

Question:

What is wrong with my code and how to do it right?

Edit (as requested by Ashley Mills):

Here is a minimal reproducible example.
Please note:
In ContentView, the custom modifier ShowAlert has been out commented. This version of the code shows the alert.
If instead the modifiers .onAppear, .onChange and .alert are out commented, and the custom modifier is enabled, the alert is not shown.

// TestViewModifierApp
import SwiftUI
@main
struct TestViewModifierApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

// ContentView
import SwiftUI
struct ContentView: View {
    @ObservedObject var alertManager = AlertManager.shared
    @State private var presentingAlert = false
    var body: some View {
        let alertManager = AlertManager.shared
        let _ = alertManager.showNextAlertMessage(title: "Title", message: "Message")
        Text("Hello, world!")
//          .modifier(ShowAlert(presentingAlert: $presentingAlert))
            .onAppear() {
                if !(alertManager.nextAlertMessage.title == nil && alertManager.nextAlertMessage.message == nil) {
                    presentingAlert = true
                }
            }
            .onChange(of: alertManager.nextAlertMessage) { alertMessage in
                presentingAlert = alertMessage.title != nil || alertMessage.message != nil
            }
            .alert(alertManager.nextAlertMessage.joinedTitle, isPresented: $presentingAlert) {
                Button("OK", role: .cancel) {
                    alertManager.alertConfirmed()
                }
            }
    }
}

// AlertManager
import SwiftUI

struct ErrorMessage: Equatable {
    let title: String?
    let message: String?
    var joinedTitle: String {
        (title ?? "") + "\n\n" + (message ?? "")
    }
    
    static func == (lhs: ErrorMessage, rhs: ErrorMessage) -> Bool {
        lhs.title == rhs.title && lhs.message == rhs.message
    }
}

final class AlertManager: NSObject, ObservableObject {
    static let shared = AlertManager() // Instantiate the singleton
    @Published var nextAlertMessage = ErrorMessage(title: nil, message: nil)
    
    func showNextAlertMessage(title: String?, message: String?) {
        DispatchQueue.main.async {
            // Publishing is only allowed from the main thread
            self.nextAlertMessage = ErrorMessage(title: title, message: message)
        }
    }
    
    func alertConfirmed() {
        showNextAlertMessage(title: nil, message: nil)
    }
}

// ShowAlert
import SwiftUI
struct ShowAlert: ViewModifier {
    @Binding var presentingAlert: Bool
    let alertManager = AlertManager.shared
    
    func body(content: Content) -> some View {
        return content
            .onAppear() {
                if !(alertManager.nextAlertMessage.title == nil && alertManager.nextAlertMessage.message == nil) {
                    presentingAlert = true
                }
            }
            .onChange(of: alertManager.nextAlertMessage) { alertMessage in
                presentingAlert = alertMessage.title != nil || alertMessage.message != nil
            }
            .alert(alertManager.nextAlertMessage.joinedTitle, isPresented: $presentingAlert) {
                Button("OK", role: .cancel) {
                    alertManager.alertConfirmed()
                }
            }
    }
}
2

There are 2 answers

1
Ashley Mills On BEST ANSWER

You're over complicating this, the way to present an error alert is as follows:

  1. Define an object that conforms to LocalizedError. The simplest way to do it is an enum, with a case for each error your app can encounter. You have to implement var errorDescription: String?, this is displayed as the alert title. If you want to display an alert message, then add a method to your enum to return this.
enum MyError: LocalizedError {
    
    case basic
    
    var errorDescription: String? {
        switch self {
        case .basic:
            return "Title"
        }
    }
    
    var errorMessage: String? {
        switch self {
        case .basic:
            return "Message"
        }
    }
}
  1. You need a @State variable to hold the error and one that's set when the alert should be presented. You can do it like this:
@State private var error: MyError?
@State private var isShowingError: Bool

but then you have two sources of truth, and you have to remember to set both each time. Alternatively, you can use a computed property for the Bool:

var isShowingError: Binding<Bool> {
    Binding {
        error != nil
    } set: { _ in
        error = nil
    }
}
  1. To display the alert, use the following modifier:
.alert(isPresented: isShowingError, error: error) { error in
    // If you want buttons other than OK, add here
} message: { error in
    if let message = error.errorMessage {
        Text(message)
    }
}

4. Extra Credit

As you did above, we can move a bunch of this stuff into a ViewModifier, so we end up with:

enum MyError: LocalizedError {
    
    case basic
    
    var errorDescription: String? {
        switch self {
        case .basic:
            return "Title"
        }
    }
    
    var errorMessage: String? {
        switch self {
        case .basic:
            return "Message"
        }
    }
}

struct ErrorAlert: ViewModifier {
    
    @Binding var error: MyError?
    var isShowingError: Binding<Bool> {
        Binding {
            error != nil
        } set: { _ in
            error = nil
        }
    }
    
    func body(content: Content) -> some View {
        content
            .alert(isPresented: isShowingError, error: error) { _ in
            } message: { error in
                if let message = error.errorMessage {
                    Text(message)
                }
            }
    }
}

extension View {
    func errorAlert(_ error: Binding<MyError?>) -> some View {
        self.modifier(ErrorAlert(error: error))
    }
}

Now to display an error, all we need is:

struct ContentView: View {
    
    @State private var error: MyError? = .basic
    
    var body: some View {
        Text("Hello, world!")
            .errorAlert($error)
    }    
}
0
tadija On

Not sure why Apple didn't provide a more simple API variant which just takes an optional Error and displays alert whenever it's not nil.

I solved it by adding this extension to the Binding:

extension Binding {
    func isNotNil<T>() -> Binding<Bool> where Value == T? {
        .init(get: {
            wrappedValue != nil
        }, set: { _ in
            wrappedValue = nil
        })
    }
}

Then it can be used with native .alert modifiers like this:

.alert(
    "Oops! Something went wrong...",
    isPresented: $vm.error.isNotNil(),
    presenting: vm.error,
    actions: { _ in },
    message: { error in
        Text(error.localizedDescription)
    }
)