Concurrency Demystified

Sep 20 2025 · Swift 6.2, iOS 26, Xcode 26

Lesson 03: Background Tasks Made Easy with Async/Await

App Improvements Demo

Episode complete

Play next episode

Next
Transcript

Now, you’ll go over the improvements you applied in this lesson.

AsyncImage

Start Xcode and open the starter project in the folder 03-background-tasks-made-easy-with-async-await.

In the ArticleView.swift file, replace the custom implementation of ArticleImageView(url:) with the stock AsyncImage:

AsyncImage(url: URL(string: url)) { image in
  image
    .resizable()
    .aspectRatio(contentMode: .fit)
    .background(.clear)
    .mask(RoundedRectangle(cornerRadius: 8))
} placeholder: {
  ProgressView()
    .frame(alignment: .center)
}
.frame(maxWidth: .infinity, alignment: .center)

AsyncImage takes the following arguments:

  • A URL containing the image’s URL address.
  • A closure that takes the loaded image and returns the view to show.
  • A closure that returns a view to present while the image is downloaded.

In your case, the image closure adapts the downloaded image with the following adjustments:

image
  .resizable()
  .aspectRatio(contentMode: .fit)
  .background(.clear)
  .mask(RoundedRectangle(cornerRadius: 8))

The closure is a simple ProgressView() displaying a spinner while the image is downloaded.

AsyncImage starts downloading the image in a background task as soon as the view is loaded on the screen.

After this change, you can remove your own implementation of the files ImageService.swift and ArticleImageView.swift.

Starting a Task on View Loading

To start downloading the news when the app is launched, you used the .task modifier on the view where you want the task to start. .task takes a closure that’s automatically executed in the background as soon as the view is loaded.

Open NewsView.swift and add the following content:

  • A state variable indicating whether the image is loading:
@State private var isLoading = false
  • Based on the value of isLoading, present ProgressView instead of the placeholder view:
.overlay {
  if isLoading {
    ProgressView()
  } else if shouldPresentContentUnavailable {
    ContentUnavailableView {
      Label("Latest News", systemImage: "newspaper.fill")
    }
  }
}
  • Finally, replace the button to load the news with a background task:
Button("Load Latest News") { newsViewModel.fetchLatestNews() }
.task {
  isLoading = true
  await newsViewModel.fetchLatestNews()
  isLoading = false
}

This task calls an asynchronous function using await and sets the value of the isLoading variable.

In the file NewsViewModel.swift, you can change the function fetchLatestNews() to asynchronous since this is now invoked in an asynchronous context (.task):

@MainActor
func fetchLatestNews() async {
  news.removeAll()
  Task {
  let news = try? await newsService.latestNews()


  self.news = news ?? []
  }
}

Since this function is called from a background thread and you’re updating a variable that triggers a UI refresh, you must use the @MainActor flag.

Refreshing Views With Pull-to-Refresh

SwiftUI natively supports the pull-to-refresh gesture. To add this feature to your app, you just need to add the .refreshable modifier to the view that you want to refresh.

Open the file NewsView.swift, and add the following content:

.refreshable {
  await newsViewModel.fetchLatestNews()
}

The .refreshable modifier takes a closure that’s executed asynchronously when the user pulls down the view on which the modifier is applied.

Using onTapGesture

Open the file NewsView.swift, and make the following changes:

  • Add the openURL system environment variable:
@Environment(\.openURL)
var openURL

This variable allows opening the system browser and loading a URL passed as an argument.

  • Next, add the .onTapGesture modifier to the ArticleView in the list:
.onTapGesture {
  if let url = article.url {
    openURL(url)
  }
}

The .onTapGesture modifier allows you to perform an action when the user taps a view. In this case, you combine this modifier with openURL to let your users open the full article content in the browser by tapping it.

  • Finally, you replace VStack with NavigationStack to set a title on the main window:
var body: some View {
  VStack(alignment: .center)NavigationStack {
    List {
      ForEach(newsViewModel.news, id: \.url) { article in
        ArticleView(article: article)
          .listRowSeparator(.hidden)
          .onTapGesture {
            if let url = article.url {
              openURL(url)
            }
          }
      }
    }
    .navigationTitle("Latest Apple News")
    .listStyle(.plain)

Implementing Persistence With an Actor

First, add the Persistence component in charge of downloading and saving the article’s image.

Create a new file named Persistence.swift, and copy the following content:

import OSLog

actor Persistence {
  func saveToDisk(_ article: Article) {
    guard let fileURL = fileName(for: article) else {
      Logger.main.error("Can't build filename for article: \(article.title)")
      return
    }

    guard let imageURL = article.urlToImage, let url = URL(string: imageURL) else {
      Logger.main.error("Can't build image URL for article: \(article.title)")
      return
    }

    Task.detached(priority: .background) {
      guard let (downloadedFileURL, response) = try? await URLSession.shared.download(from: url) else {
        Logger.main.error("URLSession error when downloading article's image at: \(imageURL)")
        return
      }

      guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
        Logger.main.error("Response error when downloading article's image at: \(imageURL)")
        return
      }

      Logger.main.info("File downloaded to: \(downloadedFileURL.absoluteString)")

      do {
        if FileManager.default.fileExists(atPath: fileURL.path) {
          try FileManager.default.removeItem(at: fileURL)
        }
        try FileManager.default.moveItem(at: downloadedFileURL, to: fileURL)
        Logger.main.info("File saved successfully to: \(fileURL.absoluteString)")
      } catch {
        Logger.main.error("File copy failed with: \(error.localizedDescription)")
      }
    }
  }

  private func fileName(for article: Article) -> URL? {
    let fileName = article.title
    guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
      return nil
    }
    return documentsDirectory.appendingPathComponent(fileName)
  }
}

Some noteworthy parts in the code above:

  • The instruction Task.detached(priority:,:) allows you to start a task detached from the actor context. In this case, since the download doesn’t trigger a UI update, you can launch it with a .background priority:
Task.detached(priority: .background) {
  ...
}
  • To download a file from a remote location, you use the URLSession’s method download(from:). This asynchronous method will return the URL of the downloaded file in the local file system and the server response:
guard let (downloadedFileURL, response) = try? await URLSession.shared.download(from: url) else {
  ...
}
  • After you verified the server response is OK, remember to move the file from the (temporary) downloaded location to its final destination:
do {
  if FileManager.default.fileExists(atPath: fileURL.path) {
    try FileManager.default.removeItem(at: fileURL)
  }
  try FileManager.default.moveItem(at: downloadedFileURL, to: fileURL)
  Logger.main.info("File saved successfully to: \(fileURL.absoluteString)")
} catch {
  Logger.main.error("File copy failed with: \(error.localizedDescription)")
}

Proceed by modifying the file ArticleView.swift to add the two new buttons in the lower-right part of the view.

Add the variables to hold the persistence object and openURL used to open the article URL in the browser:

let persistence: Persistence

@Environment(\.openURL)
var openURL

Next, replace the Text label for the date with an HStack containing the date label and the two buttons:

HStack {
  Text(article.publishedAt?.formatted() ?? "Date not available")
    .font(.caption)
  Spacer()
  Button("", systemImage: "square.and.arrow.up") {
    if let url = article.url {
      openURL(url)
    }
  }
  Button("", systemImage: "square.and.arrow.down") {
    Task { await persistence.saveToDisk(article) }
  }
}
.buttonStyle(BorderlessButtonStyle())

Finally, update the view’s preview to fix the compiler complaints:

ArticleView(article: .sample, persistence: Persistence())

Now that everything is in place, you can bind everything together: Open the file NewsView.swift. Add a shared instance of the persistence object that will be passed to all the ArticleView instances:

private let persistence = Persistence()

Then, add the persistence parameter, and remove the onTapGesture since the button in the view manages the URL opening:

ForEach(newsViewModel.news, id: \.url) { article in
  ArticleView(article: article, persistence: persistence)
    .listRowSeparator(.hidden)
    .onTapGesture {
      if let url = article.url {
        openURL(url)
      }
    }
}
See forum comments
Cinema mode Download course materials from Github
Previous: SwiftUI Background Tasks Next: Conclusion