A flexible way to handle async errors in SwiftUI
In this post, let’s take a look at how to handle async errors in a flexible and scalable way in SwiftUI. We’ll cover both completion block- and async/await-based use cases.
TL;DR
This post describes an observable AlertContext
that can used to present alerts from any place in the app, an ErrorAlertConvertible
protocol that can be implemented by Error
types that can map the error to an Alert
, and an ErrorAlerter
protocol to present errors.
Although the post contains a lot of text, the total amount of code is actually not that much. You can have a look at the already implemented code in SwiftUIKit.
The traditional way to show alerts
Consider the case where we use a standard .alert
modifier to show an alert if something goes wrong. In it’s easiest form, such a setup could look something like this:
struct MyView: View {
@State
private var isAlertPresented = false
enum MyError: Error {
case somethingWentWrong
}
var body: some View {
Button(action: doSomething) {
Text("Do something")
}.alert(isPresented: $isAlertPresented) {
Alert(title: Text("Something went wrong"))
}
}
}
private extension MyView {
func doSomething() {
doSomethingAsync { error in
isAlertPresented = error != nil
}
}
/// Fake an async call to test what happens.
func doSomethingAsync(completion: (Error?) -> Void) {
completion(MyError.somethingWentWrong)
}
}
Here, we fake an async call and sets an isPresented
flag to true
to simulate an error. It’s super-simple and not applicable in real life, but you get the idea.
To create a more flexible and robust setup, let’s come up with a way to have a single alert that can be used within the entire app, then find a way to trigger that alert in a single way.
Setting up the alert
I have alreaty written about an easier way to handle alerts in SwiftUI in this post. You can read it for more information, but let’s quickly just put it to work here as well.
Although we’ll only use alerts in this post, let’s create an observable class that can be used to manage any presentable content, such as alerts, full-screen covers, sheets, etc.:
open class PresentationContext<Content>: ObservableObject {
public init() {}
@Published
public var isActive = false
@Published
public internal(set) var content: (() -> Content)? {
didSet { isActive = content != nil }
}
public var isActiveBinding: Binding<Bool> {
.init(get: { self.isActive },
set: { self.isActive = $0 }
)
}
public func dismiss() {
isActive = false
}
public func presentContent(_ content: @autoclosure @escaping () -> Content) {
self.content = content
}
}
This context holds the active/presented state as well as a content
builder that generates the content view that should be presented.
Calling presentContent(...)
sets the content builder, which in turn sets isActive
to true
, while calling dismiss()
sets isActive
to false
.
The code is not clean. The binding is called isActiveBinding
instead of isActive
and the presentation function presentContent(...)
instead of present(...)
. You’ll soon see why.
We can now subclass PresentationContext
to create a context for handling alerts:
public class AlertContext: PresentationContext<Alert> {
public func present(_ alert: @autoclosure @escaping () -> Alert) {
presentContent(alert())
}
}
This class is super simple. By inheriting PresentationContext
and binding it to Alert
, we get a context that can be used to present alerts and alerts alone.
The class also has a cleaner present(...)
function that calls presentContent(...)
. This is the function that is meant to be used, so depending on how you setup these contexts, you could make presentContent
internal
to avoid exposing it altogether.
We’ll also create a view modifier soon, that will use the isActiveBinding
, which means that we don’t have to expose that either.
Having a non-generic presentContent
function in the base class also lets us avoid generic types by creating generic functions instead, for instance to present any view as a sheet:
public class SheetContext: PresentationContext<AnyView> {
public func present<Sheet: View>(_ sheet: @autoclosure @escaping () -> Sheet) {
presentContent(sheet().any())
}
}
With these things in mind, I’m happy to have a noisy base class, while the subclasses that are meant to be used are cleaner and tighter.
Binding the alert to the view
With AlertContext
in place, we can now create a view modifier to bind a context to a view:
public extension View {
func alert(_ context: AlertContext) -> some View {
alert(
isPresented: context.isActiveBinding,
content: context.content ?? { Alert(title: Text("")) }
)
}
}
This lets us use myView.alert(...)
just like before, but instead of a binding and a view or an item
that was later introduced, we can provide a context to get a more flexible setup.
Presenting an alert
With AlertContext
and the alert(...)
view modifier in place, we can clean up the code:
struct MyView: View {
@StateObject
private var alert = AlertContext()
enum MyError: String, Error {
case somethingWentWrong
}
var body: some View {
Button(action: doSomething) {
Text("Do something")
}.alert(alert)
}
}
private extension MyView {
func doSomething() {
doSomethingAsync { error in
guard let error = error else { return }
let title = Text(error.localizedDescription)
alert.present(Alert(title: title))
}
}
/**
Fake an async call to test what happens.
*/
func doSomethingAsync(completion: (Error?) -> Void) {
completion(MyError.somethingWentWrong)
}
}
Here, we create a StateObject
context and apply it to our button, then call .present(...)
to present an alert when our fake operation fails.
Although this is already more flexible, having this context gives us even more freedom. We could for instance inject a context from the outside, using .environmentObject(...)
:
struct ParentView: View {
@StateObject
private var alert = AlertContext()
var body: some View {
MyView()
.environmentObject(alert)
}
}
which can be accessed by replacing the @StateObject
property with @EnvironmentObject
:
struct MyView: View {
@EnvironmentObject
private var alert: AlertContext
We can also inject it with the MyView
initializer and set it up as an observed object instead:
struct ParentView: View {
@StateObject
private var alert = AlertContext()
var body: some View {
MyView(alert: alert)
}
}
struct MyView: View {
init(alert: AlertContext) {
self._alert = ObservedObject(wrappedValue: alert)
}
@ObservedObject
private var alert: AlertContext
...
}
Having this context gives us a lot more flexibility. In fact, we don’t even have to bind it in MyView
. We could just bind it within ParentView
and just pass it into the view hierarchy:
struct ParentView: View {
@StateObject
private var alert = AlertContext()
var body: some View {
MyView(alert: alert)
.alert(alert)
}
}
struct MyView: View {
init(alert: AlertContext) {
self._alert = ObservedObject(wrappedValue: alert)
}
...
func alert(_ text: String) {
alert.present(
Alert(title: text)
)
}
}
With this rather long detour, and with this setup in place, we can now start looking at some interesting ways to present error alerts when operations fail.
Alerting errors
We can now use AlertContext
to present any error as an alert. Given a general Error
, it could look something like this:
func alert(_ error: Error) {
alert.present(
Alert(title: error.localizedDescription)
)
}
However, we can do better. Let’s use protocols to get a more flexible way to handle alerts.
First, let’s define a protocol that can be implemented by any error that can be presented as an alert within our apps:
protocol ErrorAlertConvertible: Error {
var errorTitle: String { get }
var errorMessage: String { get }
}
With the required properties, the protocol can be extended to create an alert like this:
extension ErrorAlertConvertible {
var errorAlert: Alert {
Alert(
title: Text(errorTitle),
message: Text(errorMessage),
dismissButton: .default(Text("OK")) // Use localized strings though
)
}
}
We can now use this protocol to define app- or domain-specific errors, for instance:
enum MyError: ErrorAlertConvertible {
case general
var errorTitle: String {
switch self {
case .general:
return "Something went wrong"
}
}
var errorMessage: String {
switch self {
case .general:
return "Please try again"
}
}
}
We can also create app-specific enums, where each case can be converted to an Alert
.
Next, let’s define a protocol that can be implemented by types that can present errors:
protocol ErrorAlerter {
var alert: AlertContext { get }
}
With this single requirement, we can extend any type that implements ErrorAlerter
with functions that try to perform async operations and automatically alert any errors that occur.
For instance, adding this function lets an alerter alert any errors:
@MainActor
extension ErrorAlerter {
func alert(error: Error) {
if let error = error as? ErrorAlertConvertible {
return alert.present(error.errorAlert)
}
alert.present(
Alert(
title: Text(error.localizedDescription),
dismissButton: .default(Text("OK"))
)
)
}
}
Notice that since the function will change the context state, we should apply a @MainActor
to the extension. This lets us use it with async/await, since we can just await
the alert.
To support block-based operations, we could add a non-async function version as well:
extension ErrorAlerter {
func alertAsync(error: Error) {
DispatchQueue.main.async {
alert(error: error)
}
}
}
With the ErrorConvertible
and ErrorAlerter
protocols in place, we’re ready to put it all together. Let’s start with a block-based example.
Alert errors from block-based functions
Let’s put ErrorAlerter
to more work by adding an extension that can call any completion block-based operation and automatically alert any errors that occur:
extension ErrorAlerter {
typealias BlockResult<ErrorType: Error> = Result<Void, ErrorType>
typealias BlockCompletion<ErrorType: Error> = (BlockResult<ErrorType>) -> Void
typealias BlockOperation<ErrorType: Error> = (BlockCompletion<ErrorType>) -> Void
func tryWithErrorAlert<ErrorType: Error>(_ operation: @escaping BlockOperation<ErrorType>, completion: @escaping BlockCompletion<ErrorType>) {
operation { result in
switch result {
case .failure(let error): alertAsync(error: error)
case .success: break
}
completion(result)
}
}
}
Here, we define a generic BlockResult
that we use in a generic BlockCompletion
, which we then use in a generic BlockOperation
. This makes the rest of the code more readable.
We then define a tryWithErrorAlert
function that can use any parameterless function. If an error occurs, it calls alertAsync
. If the error implements ErrorAlertConvertible
, it has full control over how it will be presented, otherwise the localized description will be used.
Note that the provided completion
is called as well, to give us a way to handle the result. We can handle the error as well, we just don’t have to care about alerting it.
With this, any view with an AlertContext
can now present any error using the same alert within the entire app, by just implementing the ErrorAlerter
protocol. Pretty neat, right?
Let’s now look at how to achieve the same with Swift concurrency and async/await.
Alert errors from async functions
The code becomes cleaner with async/await, since we don’t need results, completions, etc. An async alternative to the block-based tryWithErrorAlert
function looks like this:
typealias AsyncOperation = () async throws -> Void
func tryWithErrorAlert(_ operation: @escaping AsyncOperation) {
Task {
do {
try await operation()
} catch {
await alert(error: error)
}
}
}
This means that we can perform any parameterless async throwing function and alert any error that is thrown, using the async alert function in the @MainActor
extension.
Using this approach, and with MyError
implementing ErrorAlertConvertible
from before, the initial example could look like this, if it implements ErrorAlerter
:
struct MyView: View, ErrorAlerter {
@StateObject
var alert = AlertContext()
var body: some View {
Button(action: doSomething) {
Text("Do something")
}.alert(alert)
}
}
extension MyView {
func doSomething() {
tryWithErrorAlert(doSomethingAsync)
}
/// Fake an async call to test what happens.
func doSomethingAsync() async throws {
throw(MyError.general)
}
}
We can also inject the alert context from the outside or into the rest of the view hierarchy, and use tryWithErrorAlert
in any view that implements ErrorAlerter
.
This is a lot cleaner than the block-based approach, but if your app targets an iOS version that doesn’t support async/await, you may have to stick with the block-based one for now.
Conclusion
In this article, we created an observable AlertContext
that can be passed around in an app to present alerts from anywhere with a single binding.
We then added an ErrorAlertConvertible
protocol that can be implemented by any Error
that can generate an Alert
, and an ErrorAlerter
protocol that adds extra functionality to anything with an AlertContext
instance.
Although the post contains a lot of text, the total amount of code is actually not that much. You can have a look in SwiftUIKit and use it in your own apps.
I hope you find the approach as usable as I do. I’d love to hear your thoughts on this, so don’t hesitate to comment or reach out with any thoughts you may have.
Discussions & More
If you found this interesting and would like to share your thoughts, please comment in the Disqus section below or reply to this tweet.
Follow on Twitter and Mastodon to be notified when new content & articles are published.