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, presentProgressViewinstead 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
openURLsystem environment variable:
@Environment(\.openURL)
var openURL
This variable allows opening the system browser and loading a URL passed as an argument.
-
Next, add the
.onTapGesturemodifier to theArticleViewin 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
VStackwithNavigationStackto 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.backgroundpriority:
Task.detached(priority: .background) {
...
}
-
To download a file from a remote location, you use the
URLSession’s methoddownload(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)
}
}
}