SwiftUI Background Tasks

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

@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)
  }
}

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.

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

  self.news = news ?? []
}
@State private var isLoading = false
.overlay {
  if isLoading {
    ProgressView()
  } else if shouldPresentContentUnavailable {
    ContentUnavailableView {
      Label("Latest News", systemImage: "newspaper.fill")
    }
  }
}
.task {
  isLoading = true
  await newsViewModel.fetchLatestNews()
  isLoading = false
}

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

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.

.refreshable {
  await newsViewModel.fetchLatestNews()
}

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.

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

@Environment(\.openURL)
var openURL
.onTapGesture {
  if let url = article.url {
    openURL(url)
  }
}

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.

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.

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)
  }
}

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.

let persistence: Persistence
@Environment(\.openURL)
var openURL
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())
#Preview {
  ArticleView(article: .sample, persistence: Persistence())
}
private let persistence = Persistence()

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

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
See forum comments
Download course materials from Github
Previous: Introduction Next: App Improvements Demo