Instruction

Using the Task Modifier

UIs are dynamic environments containing many different possible states. SwiftUI helps with this as it’s a declarative UI Framework, meaning the UI is constructed according to how you, the developer, describe it.

As part of these states, you’ll need to deal with situations where you need to wait for an event to occur. For example, a network request needs to fetch some information before a view can be rendered.

In earlier chapters, you handled these situations by using Taskss, independent pieces of work that run asynchronously on a separate thread. SwiftUI provides a way to trigger asynchronous work using the .task() modifier, which creates and starts a task.

Take a look at the following example:

struct TripsListView: View {
  // 1
  @State fileprivate var tripsStore = TripsStore()
  @State private var tripCount = "Loading.."
  
  var body: some View {
    Text(tripCount)
    // 2
    List(tripsStore.trips) { trip in
      TripsListRowView(trip: trip)
    }
    //3
    .task {
      let trips = await tripsStore.fetchTrips().count()
      // 4
      tripCount = "You have \(trips) planned!"
    }
  }
}

The code above does the following:

  1. The TripsListView creates a TripsStore property to retrieve trip information and a tripCount property to show the number of trips. The properties are annotated with @State, which means that when these properties are updated, the view will automatically re-render to reflect the changes.
  2. The view creates a List, using the TripsStore as the source for the list. Currently, the list is empty.
  3. The List uses the task() modifier, informing the view that some asynchronous work needs to kick off before the view appears. Inside the closure, tripStore.fetchTrips() is called to retrieve the trips, with the number of trips received returned to a property. Notice the use of await to let your app know this is an asynchronous method. When the tripStore is updated, it will pass the update to the list and show the correct number of rows.
  4. Finally, the value of tripCount is updated, which updates the Text view.

.task() provides other benefits when dealing with asynchronous code. The modifier keeps track of its own work and will cancel if the view disappears from the screen. No manual cancellation is required here! It’s also called just before the view appears, giving the view a head start to get set up before it begins to show itself.

Simplifying With AsyncImage

A common use case of apps is downloading images and displaying them. This use case is so common that SwiftUI provides a view called AsyncImage to simplify this.

Take a look at the following code:

struct TripsListRowView: View {
  // 1
  let trip: Trip
  
  var body: some View {
    //2
    AsyncImage(url: trip.imageUrl)
    // 3
    .frame(width: 200, height: 200)

    Text(trip.name)
  }
}

Here’s what that code does:

  1. TripsListRowView stores a Trip object, which serves as the view’s data source.
  2. An AsyncImage is created and requires a URL to be passed as a property. The Trip object has a property for this called imageUrl, which can be passed straight into the view.
  3. You use the .frame() modifier to set the width and height of the AsyncImage so it can constrain the image.

When the view is rendered, AsyncImage will asynchronously load an image from the specified URL, displaying it when ready.

By default, AsyncImage will use a gray placeholder while the image is loading. If you prefer to customize the placeholder, you can use the placeholder parameter to provide a view.

  AsyncImage(url: trip.imageUrl) { image in
      image.resizable()
  } placeholder: {
      YourCustomProgressView()
  }
  .frame(width: 200, height: 200)

Viewing Concurrency Performance Using Instruments

Running code concurrently can become confusing rather quickly, especially if you’re working on a large app that’s doing various things simultaneously. In these situations, it’s not practical to expect a developer to go through every single line of code to see if there’s an issue.

Fortunately, Apple provides a solution to this via Instruments, the profiling tools provided alongside Xcode to test and analyze the performance of your app.

Xcode provides an option in instruments called Swift Concurrency when you open it.

Swift Concurrency as an option in Instruments
Swift Concurrency as an option in Instruments

Choosing the option provides you with an instruments window where you can view all the tasks and actors being run by your app.

You can try it out for yourself. In the Starter folder, open TheMet.xcodeproj. From Xcode’s menu bar, select Product ▸ Profile, or press Command-I. This builds the app and launches instruments. When instruments appears, select Swift Concurrency from the template list.

Click the record button at the top left to start recording and launch the app. Then, in the app, search for something new and check the instruments window.

Running Swift Concurrency Instruments
Running Swift Concurrency Instruments

The Swift Tasks row will begin to fill up. If you select the “Swift tasks” row and then inspect the summary at the bottom of the window, you’ll see a grouped list of tasks. These tasks are what you created in earlier lessons!

The summary provides a breakdown of what tasks are running, what tasks have finished, and what tasks are suspended. This is useful information if you need to begin debugging why your app might not be behaving correctly.

Writing Asynchronous Tests

It’s good practice to verify that your asynchronous code works as expected by testing it. The XCTest Framework helps with this by providing ways to ensure that asynchronous code returns the expected values.

Take a look at this example:

    // 1
func testDownloadTripDataWithConcurrency() async throws {
  // 2
  let expectedTrips = [Trip(imageUrl: "https://tripfinder.com/trip/20234/image", name: "Trip to Scotland"),
                        Trip(imageUrl: "https://tripfinder.com/trip/99234/image", name: "Trip to New York"),
                        Trip(imageUrl: "https://tripfinder.com/trip/63154/image", name: "Trip to Kenya")]
    
  // 3
  let fetchedTrips: [Trip] = try await tripsService.fetchTrips()
    
  // 4
  XCTAssertEqual(fetchedTrips, actualTrips, "Expected trips with correct values.")
}

Here’s what the code does:

  1. It creates the test method and signals that it is an asynchronous and throwing function by appending async and throws.
  2. It creates an array of Trips that it expects to receive. For this simplified example, you assume it returns a static list, but in an actual test, you’d allow tripsService to be configured to return the expected values.
  3. The function makes an asynchronous call to tripsService.fetchTrips() to retrieve the trips. The test will pause execution while this runs. fetchTrips throws, so you call it with try, which will fail the test if it throws.
  4. The function expects that the fetchedTrips and expectedTrips are equal. If they aren’t equal, then the test fails.

Marking your test as async is the key to testing asynchronous code.

Actors can also come into play here, depending on the needs of your test code. Modify the example to assume a view model’s responsible for fetching the trips and updating the UI. In that case, you’d need the test to run on the Main Actor. You can do this by annotating your test like so:

@MainActor
func testDownloadTripDataWithConcurrency() async throws {

  let expectedTrips = [Trip(imageUrl: "https://tripfinder.com/trip/20234/image",
                            name: "Trip to Scotland"),
                        Trip(imageUrl: "https://tripfinder.com/trip/99234/image",
                            name: "Trip to New York"),
                        Trip(imageUrl: "https://tripfinder.com/trip/63154/image",
                            name: "Trip to Kenya")]

  let viewModel = TripsViewModel(tripsService: tripsService)
  try await viewModel.fetchTrips()

  XCTAssertEqual(viewModel.trips, expectedTrips, "ViewModel should have updated trips.")
}

The test function now runs on the Main Actor!

See forum comments
Download course materials from Github
Previous: Introduction Next: Demo