Mastering the SwiftUI task Modifier

Published on

Get weekly handpicked updates on Swift and SwiftUI!

With the introduction of the async/await feature in Swift 5.5, Apple has also added the task view modifier to SwiftUI, making it easier for developers to use asynchronous code based on async/await within views. This article will introduce the characteristics, usage, and precautions of the task view modifier, and provide a method for porting it to older versions of SwiftUI.

task vs onAppear

SwiftUI provides two versions of the task modifier. Version one has a similar function and timing of use to onAppear:

Swift
public func task(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View

https://cdn.fatbobman.com/image-20220806084339042.png

https://cdn.fatbobman.com/image-20220806084436930.png

Developers can use the task decorator to add asynchronous operations that occur “before the appearance” of the view.

Describing the timing of the onAppear or task closure call as “before appearance” is a last resort. In different contexts, “before appearance” may have different interpretations. For details, refer to the chapter on onAppear and onDisappear in the article “Lifecycle Study of SwiftUI Views”.

To determine whether the state of a view has changed, SwiftUI repeatedly generates instances of the view type during the view’s lifetime. Therefore, developers should avoid placing operations that can affect performance in the constructor of the view type, and instead perform such operations in onAppear or task.

Swift
struct TaskDemo1:View{
    @State var message:String?
    let url = URL(string:"<https://news.baidu.com/>")!
    var body: some View{
        VStack {
            if let message = message {
                Text(message)
            } else {
                ProgressView()
            }
        }
        .task {  // The code in the closure is executed "before" VStack appears
            do {
                var lines = 0
                for try await _ in url.lines { // Read the content of the specified URL
                    lines += 1
                }
                try? await Task.sleep(nanoseconds: 1_000_000_000) // Simulate more complex tasks
                message = "Received \(lines) lines"
            } catch {
                message = "Failed to load data"
            }
        }
    }
}

We can set the task priority to be used when creating asynchronous tasks through the priority parameter (default priority is userInitiated).

Swift
.task(priority: .background) {
    // do something
}

Task priority does not affect the thread used to create the task

task vs onChange

Another version of the task decorator provides a combined ability similar to onChange + onAppear.

Swift
public func task<T>(id value: T, priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View where T : Equatable

In addition to executing an asynchronous task once before the view appears, a new asynchronous task will be executed (creating a new one) when its observed value (which conforms to the Equatable protocol) changes:

Swift
struct TaskDemo2: View {
    @State var status: Status = .loading
    @State var reloadTrigger = false
    let url = URL(string: "https://source.unsplash.com/400x300")! // get random image url
    var body: some View {
        VStack {
            Group {
                switch status {
                case .loading:
                    Rectangle()
                        .fill(.secondary)
                        .overlay(Text("Loading"))
                case .image(let image):
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                case .error:
                    Rectangle()
                        .fill(.secondary)
                        .overlay(Text("Failed to load image"))
                }
            }
            .padding()
            .frame(width: 400, height: 300)

            Button(status.loading ? "Loading" : "Reload") {
                reloadTrigger.toggle()  // load image
            }
            .disabled(status.loading)
            .buttonStyle(.bordered)
        }
        .animation(.easeInOut, value: status)
        .task(id: reloadTrigger) { 
            do {
                status = .loading
                var bytes = [UInt8]()
                for try await byte in url.resourceBytes {
                    bytes.append(byte)
                }
                if let uiImage = UIImage(data: Data(bytes)) {
                    let image = Image(uiImage: uiImage)
                    status = .image(image)
                } else {
                    status = .error
                }
            } catch {
                status = .error
            }
        }
    }

    enum Status: Equatable {
        case loading
        case image(Image)
        case error

        var loading: Bool {
            switch self {
            case .loading:
                return true
            default:
                return false
            }
        }
    }
}

https://cdn.fatbobman.com/task_onChange_Recording_iPhone_12_Pro_15.5_2022-08-06_10.50.13.2022-08-06%2010_51_57.gif

Lifecycle of a task

The two code snippets shown above, even with network delays, do not last very long in terms of the runtime of the task closure. This does not fully utilize the advantages of a task, as we can also create asynchronous tasks that can run continuously using the task decorator:

Swift
struct TimerView:View{
    @State var date = Date.now
    @State var show = true
    var body: some View{
        VStack {
            Button(show ? "Hide Timer" : "Show Timer"){
                show.toggle()
            }
            if show {
                Text(date,format: .dateTime.hour().minute().second())
                    .task {
                        let taskID = UUID()  // task ID
                        while true { 
                            try? await Task.sleep(nanoseconds: 1_000_000_000) 
                            let now = Date.now
                            date = now
                            print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
                        }
                    }
            }
        }
    }
}

This code creates a continuous asynchronous task using the task decorator, which updates the date variable every second and displays the current task ID and time in the console.

https://cdn.fatbobman.com/task_longrun1_2022-08-07_09.07.44.2022-08-07%2009_09_38.gif

Our original intention was to use a button to toggle the display of the timer and control the lifecycle of the task (ending the task when the timer is hidden). However, after clicking the “Hide Timer” button, the app became unresponsive and the console continued to output (not following the original interval). Why did this problem occur?

The reason why the app is unresponsive is that the current task is running on the main thread. If we run the task in the background thread using the method described below, the app will continue to respond, but it will continue to update the date variable without displaying the date text, and will continue to output to the console.

Swift uses a cooperative task cancellation mechanism, which means that SwiftUI cannot directly stop the asynchronous task created by the task modifier. When the conditions for stopping the asynchronous task created by the task modifier are met, SwiftUI will send a task cancellation signal to the task, and the task must respond to the signal and stop the operation on its own.

SwiftUI will send a task cancellation signal to the asynchronous task created by the task modifier in the following two cases:

  • When the view (the view bound by the task modifier) meets the onDisappear trigger condition
  • When the bound value changes (using task to observe value changes)

To make the previous code respond to cancellation signals, we need to make the following adjustments:

Swift
// Replace
while true {
// With
while !Task.isCancelled { // Only execute the following code if the current task has not been cancelled

https://cdn.fatbobman.com/task_longrun2_2022-08-07_09.39.21.2022-08-07%2009_40_53.gif

Developers can also use the cooperative cancellation mechanism provided by Swift to achieve operations similar to onDisappear.

Swift
.task {
    let taskID = UUID()
    defer {
        print("Task \(taskID) has been cancelled.")
        // Do some data cleanup work
    }
    while !Task.isCancelled {
        try? await Task.sleep(nanoseconds: 1000000000)
        let now = Date.now
        date = now
        print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
    }
}

The thread on which task runs

When using the task modifier to create asynchronous tasks in views, in addition to the convenience of using API based on async/await syntax, developers also hope to run these tasks on background threads to reduce the burden on the main thread.

Unfortunately, all asynchronous tasks created using task in the preceding text are running in the main thread. You can check the thread on which the current task is running by adding the following statement inside the closure:

Swift
print(Thread.current)

// <_NSMainThread: 0x6000011d0b80>{number = 1, name = main}

Why does this happen? Why isn’t the task running in the background thread by default?

When using url.lines and url.resourceBytes to obtain network data, the system API will jump to the background thread, but eventually return to the main thread.

To understand and solve this problem, we need to start with the definition of the task decorator. Here is a more complete definition of the task decorator (obtained from the swiftinterface file):

Swift
@inlinable public func task(priority: _Concurrency.TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Swift.Void) -> some SwiftUI.View {
    modifier(_TaskModifier(priority: priority, action: action))
}

The @_inheritActorContext compilation attribute will bring us the answer.

https://cdn.fatbobman.com/image-20220807111608120.png

When a @Sendable async closure is marked with the @_inheritActorContext attribute, the closure will inherit the actor context (i.e. which actor it should run on) based on its declaration location. Closures that do not have a specific requirement to run on a particular actor can run anywhere (i.e. on any thread).

Returning to the current issue, since the View protocol specifies that the body property must run on the main thread (marked with @MainActor), if we directly add closure code with the task modifier in body, then the closure can only run on the main thread (the closure inherits the actor context of body).

Swift
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {
    associatedtype Body : View
    @ViewBuilder @MainActor var body: Self.Body { get }
}

If we want the closure in the task decorator to not run on the main thread, we just need to declare it in a place that doesn’t require it to run on @MainActor. For example, we can modify the timer code above to:

Swift
struct TimerView: View {
    @State var date = Date.now
    @State var show = true
    var body: some View {
        VStack {
            Button(show ? "Hide Timer" : "Show Timer") {
                show.toggle()
            }
            if show {
                Text(date, format: .dateTime.hour().minute().second())
                    .task(timer)
            }
        }
    }

    // out of body
    @Sendable
    func timer() async {
        let taskID = UUID()
        print(Thread.current)
        defer {
            print("Task \(taskID) has been cancelled.")
        }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 1000000000)
            let now = Date.now
            date = now
            print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
        }
    }
}

https://cdn.fatbobman.com/task_thread1_2022-08-07_15.21.25.2022-08-07%2015_23_01.gif

Be sure to note that if you write .task(timer) as .task{ await timer() }, it will still run in the main thread.

If you declare other Source of Truth conforming to the DynamicProperty protocol in your view (with wrappedValue and projectedValue marked as @MainActor), the above method will no longer apply. This is because SwiftUI will infer that the instance of the view type is marked with @MainActor and restricted to running on the main thread (not just the body property).

Swift
struct TimerView: View {
    @State var date = Date.now
    @State var show = true
    // In the definition of StateObject, wrappedValue and projectedValue are marked with @MainActor
    @StateObject var testObject = TestObject() // Causes SwiftUI to infer that the instance of the view type runs on the main thread by default
    var body: some View {
        VStack {
            Button(show ? "Hide Timer" : "Show Timer") {
                show.toggle()
            }
            if show {
                Text(date, format: .dateTime.hour().minute().second())
                    .task(timer)
            }
        }
    }

    // Define asynchronous function outside of the body
    @Sendable
    func timer() async {
       print(Thread.current) // Will still run on the main thread
       ....
    }
}

We can solve this problem by moving the asynchronous method outside of the view type.

In the current version of SwiftUI (prior to Swift 6), when developers declare state within a view using @StateObject, the Swift compiler implicitly treats the entire view as being annotated with @MainActor. This implicit inference behavior can easily lead to misunderstandings among developers. With the official adoption of the SE-401 proposal, starting from Swift 6, such implicit inference will no longer be permitted.

SwiftUI has made special handling for @State, which allows us to safely modify it in any thread. However, for other Source of Truth types that conform to the DynamicProperty protocol (marking wrappedValue and projectedValue with @MainActor), we must switch to the main thread before making modifications:

Swift
struct TimerView: View {
    @StateObject var object = TestObject()

    var body: some View {
        VStack {
            Button(object.show ? "Hide Timer" : "Show Timer") {
                object.show.toggle()
            }
            if object.show {
                Text(object.date, format: .dateTime.hour().minute().second())
                    .task(object.timer)
            }
        }
    }
}

class TestObject: ObservableObject {
    @Published var date: Date = .now
    @Published var show = true

    @Sendable
    func timer() async {
        let taskID = UUID()
        print(Thread.current)
        defer {
            print("Task \(taskID) has been cancelled.")
            // Do some cleanup work for the data
        }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 1000000000)
            let now = Date.now
            await MainActor.run { // Need to switch back to the main thread
                date = now
            }
            print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
        }
    }
}

task vs onReceive

Usually, we use the onReceive modifier to respond to Notification Center messages in a view. As an event source type of Source of Truth, each time a new message is received, it will cause SwiftUI to re-evaluate the body of the view.

If you want to selectively process messages, you can consider using tasks instead of onReceive, for example:

Swift
struct NotificationHandlerDemo: View {
    @State var message = ""
    var body: some View {
        Text(message)
            .task(notificationHandler)
    }

    @Sendable
    func notificationHandler() async {
        for await notification in NotificationCenter.default.notifications(named: .messageSender) where !Task.isCancelled {
            // Check whether specific conditions are met.
            if let message = notification.object as? String, condition(message) {
                self.message = message
            }
        }
    }

    func condition(_ message: String) -> Bool { message.count > 10 }
}

extension Notification.Name {
    static let messageSender = Notification.Name("messageSender")
}

In the current scenario, using tasks instead of onReceive can provide two benefits:

  • Reduce unnecessary view refreshes (avoid redundant calculations)
  • Respond to messages on a background thread to reduce the load on the main thread

Attention! task cannot completely replace onReceive. For some views (views in lazy containers, views in TabViews, etc.), they may repeatedly satisfy the triggering conditions of onAppear and onDisappear (scrolling off the screen, switching between different tabs, etc.). As a result, the notificationHandler running in task will not continue to run. However, for onReceive, even if the view triggers onDisappear, as long as the view still exists, the operations in the closure will continue to be executed (necessary information will not be lost).

Adding task modifiers to older versions of SwiftUI

Currently, Swift has backported the async/await feature to iOS 13, but has not provided task modifiers for older versions of SwiftUI (the native task modifier requires iOS 15 or higher).

After understanding the working principle and calling mechanism of the task modifier in both versions, adding task modifiers to older versions of SwiftUI will no longer be difficult.

Swift
#if canImport(_Concurrency)
import _Concurrency
import Foundation
import SwiftUI

public extension View {
    @available(iOS, introduced: 13.0, obsoleted: 15.0)
    func task(priority: TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Void) -> some View {
        modifier(_MyTaskModifier(priority: priority, action: action))
    }

    @available(iOS, introduced: 14.0, obsoleted: 15.0)
    func task<T>(id value: T, priority: TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Void) -> some View where T: Equatable {
        modifier(_MyTaskValueModifier(value: value, priority: priority, action: action))
    }
}

@available(iOS 13,*)
struct _MyTaskModifier: ViewModifier {
    @State private var currentTask: Task<Void, Never>?
    let priority: TaskPriority
    let action: @Sendable () async -> Void

    @inlinable public init(priority: TaskPriority, action: @escaping @Sendable () async -> Void) {
        self.priority = priority
        self.action = action
    }

    public func body(content: Content) -> some View {
        content
            .onAppear {
                currentTask = Task(priority: priority, operation: action)
            }
            .onDisappear {
                currentTask?.cancel()
            }
    }
}

@available(iOS 13,*)
struct _MyTaskValueModifier<Value>: ViewModifier where Value: Equatable {
    var action: @Sendable () async -> Void
    var priority: TaskPriority
    var value: Value
    @State private var currentTask: Task<Void, Never>?

    public init(value: Value, priority: TaskPriority, action: @escaping @Sendable () async -> Void) {
        self.action = action
        self.priority = priority
        self.value = value
    }

    public func body(content: Content) -> some View {
        content
            .onAppear {
                currentTask = Task(priority: priority, operation: action)
            }
            .onDisappear {
                currentTask?.cancel()
            }
            .onChange(of: value) { _ in
                currentTask?.cancel()
                currentTask = Task(priority: priority, operation: action)
            }
    }
}
#endif

You can add an onChange backward compatible version (supported by iOS 13) by yourself, so that the second version of the task modifier (onAppear + onChange) can support iOS 13.

Summary

The task modifier connects async/await and the lifecycle of SwiftUI views, allowing developers to efficiently build complex asynchronous tasks within views. However, excessive control of side effects through the task modifier in view declarations can affect the purity, testability, and reusability of views. Developers should use it judiciously.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways