Concurrency Demystified

Jun 20 2024 · Swift 5.10, iOS 17, Xcode 15.3

Lesson 03: Background Tasks Made Easy with Async/Await

App Improvements Demo

Episode complete

Play next episode

Next

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

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

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

Unlock now

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.

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)
image
  .resizable()
  .aspectRatio(contentMode: .fit)
  .background(.clear)
  .mask(RoundedRectangle(cornerRadius: 8))

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.

@State private var isLoading = false
.overlay {
  if isLoading {
    ProgressView()
  } else if shouldPresentContentUnavailable {
    ContentUnavailableView {
      Label("Latest News", systemImage: "newspaper.fill")
    }
  }
}
Button("Load Latest News") { newsViewModel.fetchLatestNews() }
.task {
  isLoading = true
  await newsViewModel.fetchLatestNews()
  isLoading = false
}
@MainActor
func fetchLatestNews() async {
  news.removeAll()
  Task {
  let news = try? await newsService.latestNews()


  self.news = news ?? []
  }
}

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.

.refreshable {
  await newsViewModel.fetchLatestNews()
}

Using onTapGesture

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

@Environment(\.openURL)
var openURL
.onTapGesture {
  if let url = article.url {
    openURL(url)
  }
}
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.

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)
  }
}
Task.detached(priority: .background) {
  ...
}
guard let (downloadedFileURL, response) = try? await URLSession.shared.download(from: url) else {
  ...
}
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)")
}
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())
ArticleView(article: .sample, persistence: Persistence())
private let persistence = Persistence()
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