It's often necessary to perform asynchronous tasks right when a view appears in SwiftUI. It's common, for instance, to initiate remote data fetching, or load and process data from the local database when a view has loaded and is shown on-screen. One way to do that is to define a Task in the action closure of the onAppear
view modifier, and add any asynchronous code there. Although it works, there's a better approach, and that is to use task; a dedicated modifier to run asynchronous code when the view appears.
Preparing the ground for .task
To demonstrate task
, we'll use a view that loads fake profiles on appear. To make things as realistic as possible, we'll begin with the following type that represents a profile programmatically:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
struct Profile: Identifiable, Hashable { let id: Int let name: String let country: String let email: String // Randomly generated names and email addresses. static let all: [Profile] = [ Profile(id: 1, name: "Sofia Kim", country: "South Korea", email: "sofia.kim@example.com"), Profile(id: 2, name: "Liam O'Reilly", country: "Ireland", email: "liam.oreilly@example.com"), Profile(id: 3, name: "Nina Patel", country: "India", email: "nina.patel@example.com"), Profile(id: 4, name: "Jasper Wang", country: "Singapore", email: "jasper.wang@example.com"), Profile(id: -1, name: "Simulated Failure", country: "Unknown", email: "fail@example.com") ] } |
Besides the few properties, there's also the all
static property that returns an array with a few Profile
objects to use in the following examples. The last one distinguishes intentionally, as we'll use it later to do some error handling.
Instead of using an actual asynchronous operation, we'll fake one. The next fetchProfile(withID:)
static method fetches and returns a matching Profile
object from these specified previously. It's defined in another custom type called FakeAsyncService
just for the sake of the simulated asynchronous process:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class FakeAsyncService { static func fetchProfile(withID id: Int) async throws -> Profile { // Simulate 1 second delay. try await Task.sleep(nanoseconds: 1_000_000_000) if let profile = Profile.all.first(where: { $0.id == id }) { if id == -1 { // Throw a fake error when the Profile with id // equal to -1 is requested. throw URLError(.badServerResponse) } return profile } else { // Throw another fake error if a Profile with // a non-existing id is requested. throw URLError(.fileDoesNotExist) } } } |
Notice that fetchProfile(withID:)
is an asynchronous throwing method. It throws two fake errors just for the demonstration if the asked profile does not exist, or the profile with id value equal to -1 is intentionally requested.
In the view implementation things are simple. If a profile has been loaded then we'll show its details, otherwise a progress indication while fetching the profile. You'll see in the following code that in case of an error we just display it, but that's just to keep things simple. Do not show actual error codes and messages to users in real apps unless it's really necessary, and handle them properly in a user-friendly way:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
struct ProfileDetailView: View { @State private var profile: Profile? @State private var error: String? var body: some View { VStack(spacing: 20) { if let profile { // Show profile details. Text(profile.name) .font(.title) Text(profile.country) Text(profile.email).foregroundColor(.blue) } else if let error { // In real apps handle the error properly! Text("Error: \(error)").foregroundColor(.red) } else { // Show a progress while fetching async data. ProgressView("Fetching profile...") } } .padding() } } |
Using .task
With the above setting the ground, let's use the task
modifier to load a profile asynchronously. In its simplest form, all we have to do is to to apply it just like we would do with onAppear
, and call the asynchronous operations in it:
1 2 3 4 5 6 7 8 |
VStack(spacing: 20) { // ... view content ... } .task { profile = try? await FakeAsyncService.fetchProfile(withID: 1) } |
See that it's not necessary to initialize a Task
block, as task
runs the code given in the closure asynchronously. Note that fetchProfile(withID:)
is an error-throwing asynchronous method, so we call it with try
and await
. This particular example requests for the first profile, so this will return the actual data after one second.
Fetching asynchronous data dynamically
The previous task
example is good enough if the asynchronous data we are fetching do not depend on any parameters. However, this is not always the case. In our scenario — as it also happens in real-world use cases — different profiles can be fetched based on the profile id we'll pass to the fetchProfile(withID:)
method. The sample view we have here could be part of an app showing the profile details once selected among others.
In order to get closer to that actual functionality, let's add one more view. It will present a list with profiles in a navigation stack, and it will navigate to the profile details when selecting one:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct ProfileListView: View { var body: some View { NavigationStack { List(Profile.all) { profile in NavigationLink(profile.name) { ProfileDetailView(profileID: profile.id) } } .navigationTitle("Profiles") } } } |
Let's update the ProfileDetailView
so it can get the profile id as argument:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ProfileDetailView: View { let profileID: Int @State private var profile: Profile? @State private var error: String? var body: some View { VStack(spacing: 20) { // ... actual content ... } .padding() .navigationTitle("Profile Details") } } |
We'll apply task
again, but this time we'll use a different initializer and we'll feed it with an additional argument; the profileID
to fetch:
1 2 3 4 5 6 7 8 9 10 |
VStack(spacing: 20) { // ... view content ... } // ... other view modifiers ... .task(id: profileID) { profile = try? await FakeAsyncService.fetchProfile(withID: profileID) } |
The id
parameter can be any value that fits the implementation, as long as it conforms to the Equatable
protocol. For clarity, id
does not refer to any identifiers related to our models or data by default. It's just a value that SwiftUI observes for changes, executing the task again when they happen. Passing a meaningful value as argument, like the profileID
in the previous example, helps us fetch dynamic data asynchronously.
The code inside the task
closure does not have to be a single line. Instead, we can apply any logic that fits the needs of the app. For instance, here we could do a better error handling in the throwing fetchProfile(withID:)
method, and handle the error case:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
.task(id: profileID) { do { profile = try await FakeAsyncService.fetchProfile( withID: profileID ) // Ensure error property is nil. error = nil } catch { self.error = error.localizedDescription // Ensure profile is nil as there is an error. profile = nil } } |
Specifying the task priority
By default, an asynchronous task initiated in task
modifier has the userInitiated
value. We can, however, set a different priority if necessary when initializing the modifier like so:
1 2 3 4 5 |
.task(priority: .background) { profile = try? await FakeAsyncService.fetchProfile(withID: 1) } |
It's also possible to provide arguments for both the id
and the priority
parameters:
1 2 3 4 5 |
.task(id: profileID, priority: .background) { profile = try? await FakeAsyncService.fetchProfile(withID: profileID) } |
Tasks are canceled automatically
Any asynchronous task initiated with task
is canceled automatically if the view is dismissed before the asynchronous operation is finished. It's easy to find that out, as Swift throws a cancellation error when a task is canceled before it completes. See the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
.task(id: profileID) { do { print("Will fetch profile with id: \(profileID)") // Delay profile fetching for 3 seconds so there's // time to navigate to the previous view. Doing so // simulates a long-running task. try await Task.sleep(nanoseconds: 3_000_000_000) // This code will be executed after 3 seconds and if // we don't navigate to the previous view. profile = try await FakeAsyncService.fetchProfile(withID: profileID) error = nil } catch is CancellationError { // This message will be printed if we go to // the previous view while task is running. print("Task was cancelled!") } catch { profile = nil self.error = error.localizedDescription } } |
We get started in this task by delaying the execution for a period of time that's sufficient to let us go back to the previous view and dismiss the current one. If we do that within the given time frame, the fetchProfile(withID:)
method won't be called. The catch
block that handles CancellationError
will be executed instead, printing the given message to the console:
If, on the other hand, we don't make the view disappear, the code in the do
block will be executed normally and the requested profile will be returned. If you're wondering, the following is taken from the Apple docs:
Cancellation Error
https://developer.apple.com/documentation/swift/cancellationerror
An error that indicates a task was canceled.
Conclusion
The task
modifier is the appropriate tool for running asynchronous code when a SwiftUI view appears. We've explored all of its forms in this post through the various initializers and the code examples that we've gone through. If you've been using onAppear
and Task
blocks, consider switching to task
, as it makes it possible to cancel the task in case the view is dismissed while still executing asynchronous code. I hope you've found this tutorial useful. Thanks for reading!