SwiftUI Background Tasks

From the Materials repo, open the Starter folder under 03-background-tasks-made-easy.

Build and run the project. Tap Load Latest News, and you’ll see that the new version of the app now shows each article’s date. It’s a subtle but nice improvement.

If you prefer to continue with the project you developed in the previous lesson, please add the following files to it. If you started from the clean project in the Starter folder, these are the files you’ll work on in this lesson.

ArticleView.swift contains the SwiftUI view for a single article. It encapsulates the different views of the article to ease their management.

Now that the article has its own view, NewsView.swift has been updated to include this new SwiftUI component.

Last but not least, you need to update the Article.swift file to add the article date (publishedAt) and its corresponding decoding in the NewsService.swift. Be sure to set the correct date decoding strategy (.iso8601); otherwise, you’ll receive an error when decoding the list of articles.

Now you’re ready to rock ‘n’ roll.

AsyncImage

The first improvements you’ll make won’t add new features but will greatly simplify the code.

Meet AsyncImage. AsyncImage is a SwiftUI component that provides a convenient way to load and display images from URLs or data asynchronously.

Here’s an overview of its key features:

  • AsyncImage can be initialized with a URL and automatically handles downloading the image asynchronously in the background.
  • It allows you to specify a placeholder view displayed while the image is loaded, preventing UI flickering or abrupt changes.
  • Similar to the regular Image view, AsyncImage supports different content modes for displaying the loaded image content. Content modes include aspect fit, aspect fill, and resizing options, allowing for flexible image presentation within the view’s frame.
  • AsyncImage provides built-in error handling for cases where the image loading fails. You can also provide a custom error view or behavior to handle such scenarios gracefully, ensuring a smooth user experience.
  • The component seamlessly integrates with SwiftUI layout systems, allowing you to incorporate it into various view hierarchies and layout structures.
  • Finally, AsyncImage leverages system-level caching mechanisms to optimize the performance and minimize redundant network requests. Specifically, cached images are automatically reused when the same URL is requested again, reducing bandwidth usage and improving load times.

By incorporating AsyncImage into a SwiftUI project, you’ll significantly streamline the image download and presentation process, enhancing the project’s efficiency and user experience. You won’t need to write dedicated components to handle image downloading and rendering, as SwiftUI handles these tasks seamlessly in the background. This change reduces development time and complexity and ensures images are loaded correctly, preventing UI freezes and enhancing performance.

To appreciate all that, open the file ArticleView.swift, and replace your ArticleImageView instantiation with the following code using AsyncImage:

@ViewBuilder
func imageView(url: String?) -> some View {
  if let url {
    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)
  }
}

The code above does the following:

  • It takes the URL with the address of the image to download.
  • It provides a closure that receives the image once it’s downloaded to customize its appearance.
  • Finally, it uses ProgressView() as a placeholder view while the image is downloaded.

Once you’ve done this, you can remove the two files ImageService.swift and ArticleImageView.swift. SwiftUI now manages everything.

Build and run the app, and verify that everything works fine.

Triggering a Task on View Loading

Wouldn’t it be nice if the app started downloading the news upon startup without requiring the user to tap “Load Latest News”? You can incorporate a task which does that as soon as the view is on screen, to enhance the performance and user experience of a SwiftUI app significantly. You’ll use the .task { } modifier that executes the provided closure in the background when the view is loaded. In the background here signifies that the task is queued simultaneous to the view load, and none of them block each other.

Start with the following changes.

Open NewsViewModel, and make the function fetchLatestNews asynchronous; the view will call it via a task, so this can now be async:

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

  self.news = news ?? []
}

Next, open NewsView.swift and add the following:

  • Add a state Boolean variable named isLoading that indicates when the network loading process is in progress:
@State private var isLoading = false
  • Change the overlay to present a ProgressView() when the app downloads the latest news from the network to give visual feedback to your users that something is going on:
.overlay {
  if isLoading {
    ProgressView()
  } else if shouldPresentContentUnavailable {
    ContentUnavailableView {
      Label("Latest News", systemImage: "newspaper.fill")
    }
  }
}
  • Then, add the .task modifier after the .overlay { … } modifier:
.task {
  isLoading = true
  await newsViewModel.fetchLatestNews()
  isLoading = false
}
  • Finally, remove the “Load Latest News” button; the app will load the latest news automatically!

Build and run the app, and you’ll see the news appear as soon as you launch it.

There’s still one caveat, though.

When running the app, Xcode complains that the update of some variables occurs in a background thread, while any updates to the UI elements should happen on the main thread. The issue is related to the fact that now the function NewsViewModel.fetchLatestNews() is called from a task, which calls it on a background thread, though in this function, you update the news variable, which drives the UI changes.

As you used to do in the old days of GCD, you must ensure these changes are performed in the main thread queue. In the Swift concurrency world, you can use the @MainActor flag on the function to indicate that once the execution resumes from await, it occurs on the main actor queue, or the main thread.

Add the flag in the file NewsViewModel.swift:

@MainActor
func fetchLatestNews() async {
  ...
}

Build and run the app, and verify that the warnings are now gone.

Nice! This approach allowed you to load your app’s interface quickly, providing users immediate access to essential content while concurrently executing resource-intensive tasks in the background.

Additionally, using tasks on view loading enable you to prioritize and manage concurrent operations effectively, ensuring optimal performance across different device configurations and network conditions.

Finally, be sure to update the UI only on the main thread, forcing the execution of these portions of code on the MainActor.

Refreshing Views With Pull-to-Refresh

Now that you removed the button to load the latest news, you need to think about how the user can force a reload once the articles are already loaded.

The most intuitive way is to use the well-known pull-to-refresh gesture.

The introduction of the .refreshable modifier in SwiftUI significantly improves the user experience by allowing for seamless content refresh within your SwiftUI views. The modifier takes a closure that’s executed asynchronously when the user pulls the view down.

Open the file NewsView.swift, and add the following code below the list style modifier:

.refreshable {
  await newsViewModel.fetchLatestNews()
}

Once more, you use await to indicate that the code is asynchronous and to let the system know it can execute it in the background without blocking UI stuff.

When NewsViewModel fetches the latest news, the UI will be updated with the new content in the news array.

Build and run the app, and try the pull-to-refresh yourself.

.refreshable offers a powerful tool seamlessly integrated with SwiftUI’s declarative syntax, making it easy to adopt and customize according to your app’s design and requirements.

By incorporating .refreshable, you enhanced Apple News responsiveness and dynamism, ensuring that your users have access to the latest data and information with minimal effort, without having to navigate away from the current screen.

Using onTapGesture

It’s now time for you to add a couple of subtle enhancements that will improve Apple News’s look, feel, and usability even more. You’ll allow the user to read any article thoroughly by opening it in the browser. You also want to add a title to the main window to make it look nicer.

First, change the main view container from a VStack to a NavigationView. This change allows you to add a title to the main view using the .navigationTitle(:) modifier:

NavigationView {
  List {
    ForEach(newsViewModel.news, id: \.url) { article in
      ArticleView(article: article)
        .listRowSeparator(.hidden)
    }
  }
  .navigationTitle("Latest Apple News")
}

Build and run the app, and check the result.

Now that you’ve changed the main view to a NavigationView, you can use the .onTapGesture modifier with the openURL environment variable to open the article URL in the browser when the user taps a row.

Make the following changes in the file NewsView.swift:

  • Add the openURL environment variable. This uses the phone’s default browser to open a provided URL:
@Environment(\.openURL)
var openURL
  • Add an .onTapGesture {} modifier to the ArticleView to open the article URL:
.onTapGesture {
  if let url = article.url {
    openURL(url)
  }
}

Build and run the app one more time, and verify that tapping on an article opens it in the phone’s browser.

Adding Persistence With an Actor

It’s time to add the last feature to Apple News to make it even more powerful. You’ll add a persistence layer to download the images to the phone’s storage so they can be reused later. For the sake of this lesson, you’ll add the download method, but reusing these images to provide a cache to Apple News isn’t too far off.

You’ll use:

  • A dedicated actor to provide the persistency layer to manage multiple downloads simultaneously.
  • A detached task to launch a task that executes in the background without being tight to the caller context.

Introducing Swift Actors

In Swift concurrency, an actor is a new language feature that facilitates safe concurrent programming by preventing data races and ensuring exclusive access to a mutable state. An actor is a reference type (just like classes) that encapsulates a state protected by a concurrency context.

Actors enforce concurrency isolation, meaning only one task can access an actor state at a time:

  • Actor internal tasks update the state synchronously.
  • Any external access to the actor is asynchronous through async/await.

That mechanism allows the actor to process these calls one at a time in its own serial execution context, ensuring thread-safe access to its mutable properties without requiring a locking mechanism or synchronization primitives.

If you’re familiar with the pre-async/await world, you might’ve implemented a similar pattern using an internal serial dispatch queue into a class. In this setup, any access to the class’s internal properties was synchronized in the queue. Actors in Swift concurrency provide a more streamlined and efficient way to achieve this, simplifying your code and improving its performance.

Implementing Persistence With an Actor

Create a new file named Persistence.swift, and add the Persistence actor:

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
    }

    // 1. This task runs in a separated context from the caller side and
    //    in the background thread
    Task.detached(priority: .background) {

      // 2. Here, you can run asynchronous functions as well
      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)
        }
        // 3. Remember to **move** the downloaded file to its final location
        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 instructions in the code above:

  1. Task.detached(priority:) {} allows an asynchronous context to start that doesn’t inherit the parent context. In this specific case, since the caller is the Persistence actor, the code in the task runs in a separate context, not “isolated” to the actor. You can specify a priority for executing the task. Here, you use .background as the download doesn’t impact the UI.
  2. To download the file, you use the URLSession’s download(from:) asynchronous method. The code is executed in the so-called global thread pool.
  3. As said in the second lesson, remember to move the temporary file to its final location in the file system.

Now that the Persistence actor is in place, you can modify the rest of the project to use it.

Binding It All Together

You’ll modify the UI to have two buttons on the bottom right side of the article view — on the opposite side of the article’s date.

Open ArticleView.swift and make the following changes:

  • Add a variable holding the instance of the persistence object; the main view will set this object when the article view is created.
let persistence: Persistence
  • Move the openURL variable from NewsView.swift:
@Environment(\.openURL)
var openURL
  • Then, replace the Text label presenting the date with an HStack that holds the Text label and the two new buttons. The first button opens the article URL, while the second asynchronously downloads the article image and saves it using the persistence actor:
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) }
  }
}
  • Finally, add the .buttonStyle(BorderlessButtonStyle()) modifier to make both buttons respond correctly to the tap:
.buttonStyle(BorderlessButtonStyle())
  • To fix the error in ArticleView’s preview, add an instance of Persistence:
#Preview {
  ArticleView(article: .sample, persistence: Persistence())
}

Finish with the last changes in NewsView.swift by removing .onTapGesture and passing the persistence instance to all the ArticleView instances:

private let persistence = Persistence()

var body: some View {
  NavigationStack {
    List {
      ForEach(newsViewModel.news, id: \.url) { article in
        ArticleView(article: article, persistence: persistence)
          .listRowSeparator(.hidden)
      }
  ...

Build and run one final time, and verify that the open URL still works fine by tapping the share button.

Finally, open the Xcode Debug Area, tap the download image button in the iOS simulator, and verify that the image is downloaded correctly.

In the Xcode debug area, you should find a message like the following:

File downloaded to: file:///Users/alessandro/Library/Developer/CoreSimulator/Devices/EDF37B83-9906-4ABD-95F4-2F5A6824E01B/data/Containers/Data/Application/8050B2E2-11BE-4CCB-8C30-482701BDEB1A/tmp/CFNetworkDownload_lMdyAZ.tmp
File saved successfully to: file:///Users/alessandro/Library/Developer/CoreSimulator/Devices/EDF37B83-9906-4ABD-95F4-2F5A6824E01B/data/Containers/Data/Application/8050B2E2-11BE-4CCB-8C30-482701BDEB1A/Documents/Goodbye%20Apple%20Car,%20Hello%20Apple%20Home%20Robots

Well done! Apple News is now ready for prime time. :]

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