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:
-
AsyncImagecan 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
Imageview,AsyncImagesupports 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. -
AsyncImageprovides 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,
AsyncImageleverages 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
isLoadingthat 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
.taskmodifier 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
openURLenvironment variable. This uses the phone’s default browser to open a provided URL:
@Environment(\.openURL)
var openURL
- Add an
.onTapGesture {}modifier to theArticleViewto 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:
-
Task.detached(priority:) {}allows an asynchronous context to start that doesn’t inherit the parent context. In this specific case, since the caller is thePersistenceactor, 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.backgroundas the download doesn’t impact the UI. - To download the file, you use the
URLSession’sdownload(from:)asynchronous method. The code is executed in the so-called global thread pool. - 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
openURLvariable from NewsView.swift:
@Environment(\.openURL)
var openURL
- Then, replace the
Textlabel presenting the date with anHStackthat holds theTextlabel and the two new buttons. The first button opens the article URL, while the second asynchronously downloads the article image and saves it using thepersistenceactor:
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 ofPersistence:
#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. :]