Teabyte

Mobile app development for iOS and Swift

SwiftUI's .task modifier

2023-03-05

While developing any kind of SwiftUI application it is often necessary to run some data loading when the view actually appears on the screen. In the past week, I implemented this kind of behaviour into applications at work and also on private projects. In all of these projects I am lucky enough to be able to use async/await, therefore SwiftUI's .task(Apple Doc) modifier is the first choice. In this small, more fundamental post I want to highlight how you can use the modifier and what you might be aware of when using it in your own projects.

Basic Usage

The .task modifier is used to execute an asynchronous task as soon as the view, to which it is attached, appears on the screen.

ContentView.swift
struct ContentView: View {
    var body: some View {
        Text("Hello World")
        .task {
           // Execute some async task
        }
    }
}

With this behaviour, the modifier becomes the natural place where you could fetch some data, which needs to be displayed on your view. E.g. in the following, we have a view that displays a list of available ice cream flavours a user can choose from.

IceCreamList.swift
struct IceCreamList: View {
    @State private var iceCreams: [IceCream]
 
    var body: some View {
        List(iceCreams) { iceCream in
            Text(iceCream.name)
        }
            .task {
                guard let iceCreamDataURL = URL(string: "https://server.com/icecreams.json") else { return }
                let (data, response) = try await URLSession.shared.data(from: iceCreamDataURL)
 
                // Here some response validation could be executed
 
                // Decode data and set state
                let jsonDecoder = JSONDecoder()
                self.iceCreams = try self.jsonDecoder.decode(Array<IceCream>.self, from: data)
            }
    }
}

Advanced Usage

Besides the basic usage of just attaching the modifier, there are two other parameters you can use to tweak the behaviour. First is that you can define the priority under which the action you supply to the modifier is run on. Per default it will be .userInitiated, the highest priority. If you need a lower priority you can just pass it as a parameter to the modifier:

    MyView()
        .task(priority: .background) { }

Another version of the modifier offers the possibility to supply an id, task(id:priority:_:) (Apple Doc)

struct IceCreamDetailView: View {
    let iceCream: IceCream
    let detailData: IceCreamDetails
 
    var body: some View {
        Text(iceCream.name)
            .task(id: iceCream) {
                // Fetch IceCream Details
            }
    }
}

It behaves exactly like the previous version of the modifier, with the difference that whenever the id changes, the eventually running task is cancelled and recreated. To detect this change the id value needs to conform to Equatable. This version is especially helpful if you are using NavigationSplitView in your application.

For example, let us have a look at the following view:

IceCreamSplitView.swift
struct IceCreamSplitView: View {
 
    // MARK: - State Properties
    @State var iceCreams: [IceCream]
    @State private var selectedIceCream: IceCream?
 
    // MARK: - Body
    var body: some View {
        NavigationSplitView {
            List(selection: $selectedSite) {
                ForEach(iceCreams) { iceCream in
                    Text(iceCream.name)
                }
            }
            .navigationTitle("IceCreams")
        } detail: {
            if let iceCream = selectedIceCream {
                NavigationStack {
                    IceCreamDetailView(iceCream: iceCream)
                }
            } else {
                Text("Please select an ice cream")
            }
        }
    }
}

In the snippet above, the init(sidebar:detail:) version of NavigationSplitView is used to create a 2-column split view. On an iPad the usage of task(id:priority:_:) is crucial for our detail view now. Since, once the user has selected an ice cream, the detail view will not appear again, meaning the basic version of the task modifier will not trigger the action of the task modifier again. By using the selected ice cream as the trigger for our asynchronous action, this limitation can be mitigated and whenever the user changes their selection the detail data is fetched again. Exactly how it should be.

Comparison vs .onAppear

After having a deeper look at the task modifier, you might ask yourself what the difference is to the .onAppear modifier which on the surface offers the same behaviour. Both modifiers can run synchronous methods when the view appears. But .task has two advantages:

  1. Native async/await support which eliminates the need of wrapping the task into Task { }
  2. It offers automatic task cancellation when the view goes out of scope or when used in the task(id:_:) version, the id changes, which makes our lives easier and we do not need to handle the lifetime of the task

Both modifiers have their use cases. It's up to you, to decide which one you need.

Conclusion

In this small post, we had a look at the three different ways of using the .task modifier to trigger asynchronous actions when a view appears:

  1. task(_:) in the simplest form without any priority supplied
  2. task(priority:_:) with a priority supplied
  3. task(id:priority:_:) to automatically cancel and recreate tasks on id changes

I hope this overview helps you to choose the correct version for your next project. If you find any mistake or would like to reach out to me please do so 🙂

See you next time! 👋