Async await: Update SwiftUI view

In this post, we are going to showcase some common examples of working with async await and update a SwiftUI view. Let’s consider a scenario where we need to perform asynchronous work when the view appears, such as loading an image. After that, we will update the SwiftUI view.

.task modifier

For this purpose, we have the perform modifier for the job, the .task modifier:

Swift
struct ContentView: View {
    @State var data: Data?
    
    var body: some View {
        VStack {
            if let data = data,
               let image = UIImage(data: data) {
                Image(uiImage: image)
                    .resizable()
            }
        }
        .task {
            do {
                let url = URL(string: "https://developer.apple.com/wwdc23/topics/images/og/swift-og.png")!
                data = try await URLSession.shared.data(from: url).0
            } catch {
                //Handle error
            }
        }
    }
}

Since this modifier creates its own asynchronous context, we can perform any asynchronous block. Additionally, it will handle cancellation for us, so as soon as the view is dismissed, the task will be canceled.

But what about if we need to perform some asynchronous work inside a closure, such as after tapping a button? We cannot use the modifier within the closure. To perform asynchronous work in such cases, we need to create an asynchronous context, and for that, we can use the Task type:

Swift
struct ContentView: View {
    @State var data: Data?
    
    var body: some View {
        VStack {
            if let data = data,
               let image = UIImage(data: data) {
                Image(uiImage: image)
                    .resizable()
            }
            
            Button(action: {
                Task {
                    do {
                        let url = URL(string: "https://developer.apple.com/wwdc23/topics/images/og/swift-og.png")!
                        data = try await URLSession.shared.data(from: url).0
                    } catch {
                        //Handle error
                    }
                }
            }, label: {
                Text("Load image")
            })
        }
    }
}

In this case, cancellation is not handled automatically, so it’s up to us to handle it. We explore how to handle cancellation in this article.

Performing updates

Just like in UIKit, we need to be cautious when performing UI updates on the main thread. When we create a new asynchronous context, this context inherits the priority from the current one. So when we do this inside a SwiftUI view, which is executed by the main actor, our asynchronous context starts on the main thread, allowing us to perform UI state changes initially. However, after any await instruction, we have no guarantee about the thread we’re on. Fortunately, if you try any of the previous examples, everything works fine because when we update a @State variable, SwiftUI is smart enough to understand that it needs to be done on the main thread. Let’s try a different but common approach using an @Published property instead of an @State variable.

A common example involves having an observable model that can perform asynchronous work and update a @Published property, which is observed by the view

Swift
final class ContentObservableModel: ObservableObject {
    @Published var data: Data?
    
    func loadImage() async throws {
        let url = URL(string: "https://developer.apple.com/wwdc23/topics/images/og/swift-og.png")!
        data = try await URLSession.shared.data(from: url).0
    }
}

struct ContentView: View {
    @ObservedObject var model: ContentObservableModel
    
    var body: some View {
        VStack {
            if let data = model.data,
               let image = UIImage(data: data) {
                Image(uiImage: image)
                    .resizable()
            }
            
            Button(action: {
                Task {
                    do {
                        try await model.loadImage()
                    } catch {
                        //Handle error
                    }
                }
            }, label: {
                Text("Load image")
            })
        }
        .padding()
    }
}

If we execute the previous code, we will receive an error related to updating the UI from a background thread:

Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

Update SwiftUI view on the main thread when using Async Await

We need to ensure that the property is updated on the main thread. There are different ways to ensure this.

One approach is to use MainActor.run, where whatever we do in the closure is executed on the main actor:

Swift
final class ContentObservableModel: ObservableObject {
    @Published var data: Data?
    
    func loadImage() async throws {
        let url = URL(string: "https://developer.apple.com/wwdc23/topics/images/og/swift-og.png")!
        let result = try await URLSession.shared.data(from: url).0
        
        await MainActor.run {
            data = result
        }
    }
}

By using the @MainActor property wrapper, which can be added to any function, updating the UI becomes a straightforward task

Swift
final class ContentObservableModel: ObservableObject {
    @MainActor @Published var data: Data?
    
    func loadImage() async throws {
        let url = URL(string: "https://developer.apple.com/wwdc23/topics/images/og/swift-og.png")!
        let result = try await URLSession.shared.data(from: url).0
        await updateData(result)
    }
    
    @MainActor func updateData(_ newData: Data) async {
        data = newData
    }
}

Conclusion

We’ve explored various options for initiating asynchronous work from a SwiftUI view and noted that the same rules for updating the UI from the main thread still apply to SwiftUI. In my next article, I want to focus on how to handle cancellation properly. See you then!