Alert Coordinator in SwiftUI with @Observable

Displaying alerts in SwiftUI is easy, but every view that displays alerts has to add the .alert() modifier, which makes the code quite difficult to read at the end of the day. Alerts can be displayed simultaneously but only 1 alert can ever be presented and acted upon by the user at the time. It would be better if we could abstract this away and allow views and view models to display alerts without having to re-implement the .alert() modifier per view/view model.

In this post we will create an alert coordinator with Swift’s @Observable pattern.

Our Goal

Look at this view model! By the end of this article we will be able to display alerts from anywhere, view, view model, etc, by simply calling a showAlert() function which we take in using the @ShowAlert property wrapper. Alerts can be synchronous or asynchronous. If they are synchronous, we will be able to get a call-back when any of the buttons are tapped, like this:

extension ContentView {
  final class ViewModel {
    @ShowAlert private var showAlert

    @MainActor
    func displayAlert() {
      showAlert(
        Alert(
          title: "Title",
          message: "Message",
          actions: [
            .init(
              title: "Button 1",
              onPressed: {
                print("Button 1 pressed")
              }
            ),
            .init(
              title: "Button 2",
              onPressed: {
                print("Button 2 pressed")
              }
            ),
          ]
        )
      )
    }
  }
}

And in case of displaying asynchronous alerts, we can use another type of alert which we’ll call AsyncAlert:

extension ContentView {
  final class ViewModel {
    @ShowAlert private var showAlert

    @MainActor
    func displayAlert() async -> Int {
      await showAlert(
        AsyncAlert(
          title: "Title",
          message: "Message",
          actions: [
            .init(
              title: "Button 1",
              value: 10
            ),
            .init(
              title: "Button 2",
              value: 20
            ),
          ]
        )
      )
    }
  }
}

Note how the displayAlert() function in the view model returns an Int asynchronously! That all works because the showAlert() function when displaying an AsyncAlert returns a generic value with which the alert actions are configured with. In this case, an Int! Don’t worry if this all sounds confusing for now! We will learn about this together.

Synchronous Alert Structure

The basis to all of this is an alert structure. An alert has to have:

  • Title
  • Message
  • Actions (array)

Let’s define this alert structure:

@MainActor
struct Alert {
  let title: LocalizedStringKey
  let message: LocalizedStringKey

  let actions: [Action]?

  init(
    title: LocalizedStringKey? = nil,
    message: LocalizedStringKey? = nil,
    actions: [Action]? = nil
  ) {
    self.title = title ?? ""
    self.message = message ?? ""
    self.actions = actions
  }
}

This is enough for us to be able to start implementing the alert coordinator itself. The alert coordinator has to have a published property that the root view of our project can read from, and display an alert based on.

Alert Actions

The Alert structure should be able to also define Action as seen above so let’s start with that now:

extension Alert {
  struct Action: Identifiable {
    let id = UUID()
    let title: LocalizedStringKey
    let onPressed: () -> Void
    let role: ButtonRole?

    init(
      title: LocalizedStringKey,
      role: ButtonRole? = nil,
      onPressed: @escaping () -> Void
    ) {
      self.title = title
      self.role = role
      self.onPressed = onPressed
    }
  }
}

So that’s for the Action of each Alert.

Alert Views

Let’s go ahead and create an extension on Alert that produces views for the message and all the actions of the Alert:

extension Alert {
  func messageView() -> some View {
    Text(message)
  }

  func actionsView() -> some View {
    ForEach(actions ?? []) { action in
      Button(role: action.role) {
        action.onPressed()
      } label: {
        Text(action.title)
      }
    }
  }
}