UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

How to run tasks using SwiftUI’s task() modifier

Paul Hudson    @twostraws   

Updated for Xcode 15

SwiftUI provides a task() modifier that starts a new detached task as soon as a view appears, and automatically cancels the task when the view disappears. This is sort of the equivalent of starting a task in onAppear() then cancelling it onDisappear(), although task() has an extra ability to track an identifier and restart its task when the identifier changes.

In the simplest scenario – and probably the one you’re going to use the most – task() is the best way to load your view’s initial data, which might be loaded from local storage or by fetching and decoding a remote URL.

For example, this downloads data from a server and decodes it into an array for display in a list:

struct Message: Decodable, Identifiable {
    let id: Int
    let from: String
    let text: String
}

struct ContentView: View {
    @State private var messages = [Message]()

    var body: some View {
        NavigationView {
            List(messages) { message in
                VStack(alignment: .leading) {
                    Text(message.from)
                        .font(.headline)

                    Text(message.text)
                }
            }
            .navigationTitle("Inbox")
            .task {
                await loadMessages()
            }
        }
    }

    func loadMessages() async {
        do {
            let url = URL(string: "https://hws.dev/messages.json")!
            let (data, _) = try await URLSession.shared.data(from: url)
            messages = try JSONDecoder().decode([Message].self, from: data)
        } catch {
            messages = [
                Message(id: 0, from: "Failed to load inbox.", text: "Please try again later.")
            ]
        }
    }
}

Download this as an Xcode project

Important: The task() modifier is a great place to load the data for your SwiftUI views. Remember, they can be recreated many times over the lifetime of your app, so you should avoid putting this kind of work into their initializers if possible.

A more advanced usage of task() is to attach some kind of Equatable identifying value – when that value changes SwiftUI will automatically cancel the previous task and create a new task with the new value. This might be some shared app state, such as whether the user is logged in or not, or some local state, such as what kind of filter to apply to some data.

As an example, we could upgrade our messaging view to support both an Inbox and a Sent box, both fetched and decoded using the same task() modifier. By setting the message box type as the identifier for the task with .task(id: selectedBox), SwiftUI will automatically update its message list every time the selection changes.

Here’s how that looks in code:

struct Message: Decodable, Identifiable {
    let id: Int
    let user: String
    let text: String
}

// Our content view is able to handle two kinds of message box now.
struct ContentView: View {
    @State private var messages = [Message]()
    @State private var selectedBox = "Inbox"
    let messageBoxes = ["Inbox", "Sent"]

    var body: some View {
        NavigationView {
            List {
                Section {
                    ForEach(messages) { message in
                        VStack(alignment: .leading) {
                            Text(message.user)
                                .font(.headline)

                            Text(message.text)
                        }
                    }
                }
            }
            .listStyle(.insetGrouped)
            .navigationTitle(selectedBox)

            // Our task modifier will recreate its fetchData() task whenever selectedBox changes
            .task(id: selectedBox) {
                await fetchData()
            }
            .toolbar {
                // Switch between our two message boxes
                Picker("Select a message box", selection: $selectedBox) {
                    ForEach(messageBoxes, id: \.self, content: Text.init)
                }
                .pickerStyle(.segmented)
            }
        }
    }

    // This is almost the same as before, but now loads the selectedBox JSON file rather than always loading the inbox.
    func fetchData() async {
        do {
            let url = URL(string: "https://hws.dev/\(selectedBox.lowercased()).json")!
            let (data, _) = try await URLSession.shared.data(from: url)
            messages = try JSONDecoder().decode([Message].self, from: data)
        } catch {
            messages = [
                Message(id: 0, user: "Failed to load message box.", text: "Please try again later.")
            ]
        }
    }
}

Download this as an Xcode project

Tip: That example uses the shared URLSession, which means it will cache its responses and so load the two inboxes only once. If that’s what you want you’re all set, but if you want it to always fetch the files make sure you create your own session configuration and disable caching.

One particularly interesting use case for task() is with AsyncSequence collections that continuously generate values. This might be a server that maintains an open connection while sending fresh content, it might be the URLWatcher example we looked at previously, or perhaps just a local value. For example, we could write a simple random number generator that regularly emits new random numbers – with the task() modifier we can constantly watch that for changes, and stream the results into a SwiftUI view.

To bring this example to life, we’re going to add one more thing: the random number generator will print a message every time a number is generated, and the resulting number list will be shown inside a detail view. Both of these are done so you can see how task() automatically cancels its work: the numbers will automatically start streaming when the detail view is shown, and stop streaming when the view is dismissed.

Here’s the code:

// A simple random number generator sequence
struct NumberGenerator: AsyncSequence, AsyncIteratorProtocol {
    typealias Element = Int
    let delay: Double
    let range: ClosedRange<Int>

    init(in range: ClosedRange<Int>, delay: Double = 1) {
        self.range = range
        self.delay = delay
    }

    mutating func next() async -> Int? {
        // Make sure we stop emitting numbers when our task is cancelled
        while Task.isCancelled == false {
            try? await Task.sleep(nanoseconds: UInt64(delay) * 1_000_000_000)
            print("Generating number")
            return Int.random(in: range)
        }

        return nil
    }

    func makeAsyncIterator() -> NumberGenerator {
        self
    }
}

// This exists solely to show DetailView when requested.
struct ContentView: View {
    var body: some View {
        NavigationView {
            NavigationLink(destination: DetailView()) {
                Text("Start Generating Numbers")
            }
        }
    }
}

// This generates and displays all the random numbers we've generated.
struct DetailView: View {
    @State private var numbers = [String]()
    let generator = NumberGenerator(in: 1...100)

    var body: some View {
        List(numbers, id: \.self, rowContent: Text.init)
            .task {
                await generateNumbers()
            }
    }

    func generateNumbers() async {
        for await number in generator {
            numbers.insert("\(numbers.count + 1). \(number)", at: 0)
        }
    }
}

Download this as an Xcode project

Notice how the generateNumbers() method at the end doesn’t actually have any way of exiting? That’s because it will exit automatically when generator stops returning values, which will happen when the task is cancelled, and that will happen when DetailView is dismissed – it takes no special work from us.

Tip: The task() modifier accepts a priority parameter if you want fine-grained control over your task’s priority. For example, use .task(priority: .low) to create a low-priority task.

Hacking with Swift is sponsored by Essential Developer

SPONSORED Join a FREE crash course for mid/senior iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a complete senior developer! Hurry up because it'll be available only until April 28th.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Similar solutions…

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.2/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.