Many modern iOS apps are connected to the internet.
When you need to download or upload data, URLSession
is the solution.
Together with other types, URLSession
not only transfers data over a network but also groups transfers together.
This allows you to:
- Optimize data transfers.
- Handle authentication, cookies, and caching.
- Pause, resume, or cancel network transfers.
- Download data in the background when your app is suspended.
Due to the complexity of network transfers, using URLSession
is not straightforward and presents a few architectural challenges, especially in SwiftUI apps.
We will explore all of the above in this article.
Architecting SwiftUI apps with MVC and MVVM
GET THE FREE BOOK NOWTable of contents
Chapter 1
How the URLSession type constellation manages network transfers
Several complex protocols define the internet.
While downloading data from a server might seem straightforward to a developer, it relies on several moving parts acting in concert.
- What is URLSession, and how does it handle internet protocols?
- You use sessions to group and manage related network transfers
- The URLSession constellation of types manages download and upload tasks
What is URLSession, and how does it handle internet protocols?
URLSession
is the central class of the URL Loading System on Apple platforms. You use it in iOS and macOS apps to transfer data asynchronously through standard internet protocols.
In modern iOS apps, you often need to fetch data from, and sometimes send data to, a remote server identified by a URL.
In theory, this process is as straightforward as loading the contents of the URL using the Data
type, which you can do in a couple of lines of code.
import Foundation
// Never do this
let url = URL(string: "https://matteomanferdini.com/swift-async-await")!
let data = try Data(contentsOf: url)
But things are not that simple. Why?
Firstly, this method is synchronous, which will freeze your app until the transfer is completed. But that’s not the biggest problem.
The internet is defined by a series of layered protocols that describe how to transfer data across computers through connected networks.
As an iOS developer, you can usually ignore the lowest layers: the link, the internet, and the transport layers. But you have to pick one of the protocols in the application layer to define how your app will communicate with a server.
The most common protocol you will use in iOS apps is HTTP, usually in its secure extension. But URLSession
also supports FTP and custom networking protocols. The Data
type, instead, does not allow you to pick the protocol.
You use sessions to group and manage related network transfers
Picking the correct protocol is only part of the picture. After all, the init(contentsOf:)
initializer of the Data
type can transfer data alone.
How it does that is undocumented, but it surely uses HTTP under the hood, or the server would not respond.
Couldn’t the Data
type provide an asynchronous API that lets us pick the protocol and configure its parameters?
The Foundation framework offered the NSURLConnection class, now deprecated, that did exactly that. But handling internet connections one at a time is tedious and error-prone.
The benefits of grouping HTTP requests in a session
The best example to understand why a session is needed – instead of the Data
type – to fetch from and send data to a remote server identified by a URL is to look at what happens when you load a web page like this in your browser.
Behind the scenes, a browser loads several files:
- The HTML file with the page’s content and the URLs of all other resources.
- The CSS style sheets that define the visual design of the page.
- Every image on the page.
- Additional JavaScript files that define some behaviors, like the formatting of code snippets.
Most of these files are loaded from the same server and often share characteristics like caching, cookies, and authentication.
Moreover, if the user cancels or restarts the loading of a page, all the connections need to be managed as a group.
On top of that, HTTP/2 introduced server push, which allows a server to speed up the loading of related resources by sending them to a client before the client requests them.
HTTP/3 also changes how sessions are maintained, delivering further speed improvements.
These are the main reasons why the URLSession
class exists.
The URLSession constellation of types manages download and upload tasks
With such complexity to manage, it’s no surprise that URLSession
is the central hub of a constellation of types that allows you to configure every aspect of a session to group multiple requests.
Many types make up the URL Loading System, and it would be impractical to list them here. You will need these principal types when you work with URLSession
.
- A
URLSession
instance is configured using aURLSessionConfiguration
object. This defines the common attributes of all network transfers like caching, HTTP headers, security, etc. - A session can have a delegate conforming to the
URLSessionDelegate
protocol, which handles the session lifecycle and authentication challenges. - Through a session, you create instances of
URLSessionTask
for each network transfer. - A task takes either a destination
URL
value or aURLRequest
value which defines the parameters of a single request, like the HTTP method or specific HTTP headers. - Upon termination, a task returns a
Data
value with the content of the response and aURLResponse
containing additional properties.
Selecting the appropriate task type to handle downloads, uploads, and naked TCP/IP connections
The URLSessionTask
class is the base for all tasks handled by URLSession
. However, you never create a task instance directly. Instead, you use one of the URLSession
methods to create a specific subclass of URLSessionTask
depending on your goal.
For example:
- You use the
URLSessionDataTask
class to download data directly to the app in memory. This is the task you will use the most often since most APIs return data that needs to be parsed and displayed directly to the user. - A
URLSessionDownloadTask
instance also downloads data but saves it directly into a temporary file on disk. You typically use this task to download large documents and media files you need to persist on the disk. You also use this class for background downloads when the user closes your app. - The
URLSessionUploadTask
class is used to upload data. You usually use it for HTTP requests with a body, such asPOST
andPUT
. - Recently Apple introduced the
URLSessionWebSocketTask
. While WebSocket is primarily used in interactive web applications, it’s also useful for iOS apps that require live data like stock prices or scores for sport events. - And finally, the
URLSessionStreamTask
provides an interface to a naked TCP/IP connection without using an application layer protocol like HTTP. Don’t be confused by its name. Media streaming is usually built on top of other application layer protocols like HTTP Live Streaming, so to do it, you would use one of the other task classes, probablyURLSessionDataTask
.
Chapter 2
Modeling the data fetched through a URLSession
Despite its complexity, downloading data with URLSession
can be, at times, as straightforward as a couple of lines of code.
That’s because URLSession
only transfers data over the internet and leaves the developer responsible for handling the downloaded data appropriately.
- Creating a SwiftUI app to download podcast episodes from the iTunes Search API
- Decoding the JSON data returned by a data task
- Storing download status and progress in a single source of truth
Creating a SwiftUI app to download podcast episodes from the iTunes Search API
Throughout the rest of the article, we will build an example podcast app. This will show you how to use URLSession
and the appropriate tasks to download the podcast information and episodes.
This article is not a step-by-step tutorial, so I will not show the process of writing the required code. But I will explain why I take specific decisions in the app architecture.
We will use Apple’s iTunes Search API, which allows you to search for content within the iTunes Store. You can find the same information in this archived guide with a more readable format.
You can search for all sorts of content by adding query parameters to the iTunes search URL. If you want to construct such a URL, you can read my article on URL components.
For our simple example, we will load the last five episodes of The Symbolic World, a podcast I have enjoyed for many years.
The search URL to fetch the podcast information is https://itunes.apple.com/lookup?id=1386867488&media=podcast&entity=podcastEpisode&limit=5
.
You can find the sample code for the app on GitHub, with commits following the evolution of the code in the article and tags for each chapter.
Decoding the JSON data returned by a data task
The most commonly used task in iOS apps is URLSessionDataTask
, which you use to fetch data and immediately display it to the user.
Data tasks are agnostic and return the downloaded data as a binary Data
value. It is your responsibility to decode and use the data appropriately.
Often, the data is in JSON format, like in this example.
{
"resultCount":6,
"results":[
{
"collectionId":1386867488,
"artistName":"Jonathan Pageau",
"collectionName":"The Symbolic World",
"artworkUrl600":"https://is5-ssl.mzstatic.com/image/thumb/Podcasts125/v4/1a/b1/d4/1ab1d4b8-ae33-da25-d3c1-b919bbf6b9e0/mza_4176360441897917495.jpg/600x600bb.jpg"
},
{
"trackTimeMillis":3017000,
"collectionId":1386867488,
"trackId":1000600017070,
"trackName":"275 - Michael Legaspi - Subjectivity and the Psalms",
"releaseDate":"2023-02-16T16:00:17Z",
"episodeUrl":"https://feeds.soundcloud.com/stream/1447464973-jonathan-pageau-307491252-275-michael-legaspi.mp3"
}
}
In the code above, I have simplified the JSON data, removing all the members we don’t need. The first object in the results
array contains the podcast information.
All the objects after that are podcast episodes. I only included one above, but the complete response contains five because of the limit=5
query parameter in the URL.
Model types don’t have to match the structure of JSON data
Decoding the JSON data for a podcast episode is straightforward.
struct Episode: Identifiable {
let id: Int
let podcastID: Int
let duration: Duration
let title: String
let date: Date
let url: URL
}
extension Episode: Decodable {
enum CodingKeys: String, CodingKey {
case id = "trackId"
case podcastID = "collectionId"
case duration = "trackTimeMillis"
case title = "trackName"
case date = "releaseDate"
case url = "episodeUrl"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.podcastID = try container.decode(Int.self, forKey: .podcastID)
let duration = try container.decode(Int.self, forKey: .duration)
self.duration = .milliseconds(duration)
self.title = try container.decode(String.self, forKey: .title)
self.date = try container.decode(Date.self, forKey: .date)
self.url = try container.decode(URL.self, forKey: .url)
}
}
The same code would work to decode the podcast information. But, since the podcast data is in the results
array together with the data for the episode, we would then have problems decoding the entire lookup object returned by the iTunes Search API.
Theoretically, we could create another model type for that, but that approach would be a poor choice. Due to Swift’s strong typing, the results
array would need to be of type [Any]
, resulting in a lot of typecasting when you read its contents.
But nothing binds us to that structure.
In our app’s model, placing the episodes inside the Podcast
type makes more sense. Then, we can use an UnkeyedDecodingContainer
to decode the whole lookup object inside the Podcast
type.
struct Podcast {
let id: Int
let title: String
let artist: String
let imageURL: URL
var episodes: [Episode]
}
extension Podcast: Decodable {
enum CodingKeys: String, CodingKey {
case id = "collectionId"
case title = "collectionName"
case artist = "artistName"
case imageURL = "artworkUrl600"
}
enum LookupCodingKeys: CodingKey {
case results
}
init(from decoder: Decoder) throws {
let lookupContainer = try decoder.container(keyedBy: LookupCodingKeys.self)
var resultsContainer = try lookupContainer.nestedUnkeyedContainer(forKey: LookupCodingKeys.results)
let podcastContainer = try resultsContainer.nestedContainer(keyedBy: CodingKeys.self)
self.id = try podcastContainer.decode(Int.self, forKey: .id)
self.title = try podcastContainer.decode(String.self, forKey: .title)
self.artist = try podcastContainer.decode(String.self, forKey: .artist)
self.imageURL = try podcastContainer.decode(URL.self, forKey: .imageURL)
var episodes: [Episode] = []
while !resultsContainer.isAtEnd {
let episode = try resultsContainer.decode(Episode.self)
episodes.append(episode)
}
self.episodes = episodes
}
}
You can find everything you need to know about basic and advanced JSON decoding in my article on decoding JSON data using the Codable
protocols.
Storing download status and progress in a single source of truth
Download tasks report the progress of a download as it progresses. Our code needs to store that information somewhere and pass it to our user interface so that it can show a progress bar and the appropriate icon.
Many considerations go into such a choice, depending on the architecture of your app. In our example, I placed the information directly inside the Episode
model type.
struct Episode: Identifiable {
let id: Int
let podcastID: Int
let duration: Duration
let title: String
let date: Date
let url: URL
var isDownloading: Bool = false
private(set) var currentBytes: Int64 = 0
private(set) var totalBytes: Int64 = 0
var progress: Double {
guard totalBytes > 0 else { return 0.0 }
return Double(currentBytes) / Double(totalBytes)
}
mutating func update(currentBytes: Int64, totalBytes: Int64) {
self.currentBytes = currentBytes
self.totalBytes = totalBytes
}
}
There are several reasons for this choice.
In SwiftUI, a piece of information should be preserved in a single source of truth. In this case, it made sense to keep the data of an episode and its download status together since these pieces of information are related.
This will make it easier to deliver all the information at once to the user interface and trigger updates in the SwiftUI view hierarchy whenever such data changes.
In an app that downloads files like our example, you will also likely want to store the download status of each file on disk to resume the download after the app is terminated.
Model types are likely to be stored using the Encodable
protocol. Putting the download information in the Episode
type also simplifies saving and loading that information.
You can have several single sources of truth in a SwiftUI app
Beware that we could have kept the information separate in our app’s architecture if it made sense. It would make pairing each episode with its download status more complicated, but it would not be impossible.
Many developers mistakenly think that having a single source of truth means that all data should be gathered in a single centralized location.
But that is not what a single source of truth means. It only means that state should not be replicated, or keeping multiple copies in sync will become increasingly complicated.
You can have several single sources of truth in your app if their information does not overlap.
Chapter 3
Managing the asynchronous progress updates coming from a download task
In a SwiftUI app, the networking code usually goes inside view models following the MVVM pattern for SwiftUI apps.
In our simple app, that’s all we need. A shared MVC controller is also usually required in apps with complex navigation that handle multiple network requests.
- Picking the proper URLSession configuration for your app’s needs
- Creating download tasks and receiving progress updates
- Transforming delegate callbacks into an asynchronous sequence of events
Picking the proper URLSession configuration for your app’s needs
In iOS apps, we often need to make simple data requests over HTTP with no authentication or other particular parameters. We only need to fetch some data from a URL and display it directly in our user interface.
For that, we can use the shared URLSession
singleton. This is a convenient way to fetch data in as little as just one line of code, but it has some limitations.
class ViewModel: ObservableObject {
@Published var podcast: Podcast?
@MainActor
func fetchPodcast() async throws {
let url = URL(string: "https://itunes.apple.com/lookup?id=1386867488&media=podcast&entity=podcastEpisode&limit=5")!
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
podcast = try decoder.decode(Podcast.self, from: data)
}
}
Since the shared session is a singleton, all the usual considerations about singletons apply.
The data(from:delegate:)
method of URLSession
returns the downloaded data asynchronously using the new async-await concurrency paradigm introduced in Swift 5.5.
Behind the scenes, it creates a URLSessionDataTask
instance to manage the download. The method also takes an optional delegate object conforming to URLSessionTaskDelegate
, but we don’t need one here.
Creating a default session instance to group related download tasks
We can’t use the shared session to download the podcast episodes. Apple’s documentation also lists the following limitations:
- You can’t obtain data incrementally as it arrives from the server.
- You can’t perform background downloads or uploads when your app isn’t running.
Instead, we need to create a separate session object configured using a URLSessionConfiguration
object.
class ViewModel: NSObject, ObservableObject {
@Published var podcast: Podcast?
private lazy var downloadSession: URLSession = {
let configuration = URLSessionConfiguration.default
return URLSession(configuration: configuration, delegate: nil, delegateQueue: .main)
}()
@MainActor
func fetchPodcast() async throws {
let url = URL(string: "https://itunes.apple.com/lookup?id=1386867488&media=podcast&entity=podcastEpisode&limit=5")!
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
podcast = try decoder.decode(Podcast.self, from: data)
}
}
Using a background session to download files while the app is suspended
In the code above, I picked a default
session configuration, which will be sufficient for our app.
If you want the system to continue your downloads in the background when your app is suspended, you must use the background(withIdentifier:)
class function of URLSessionConfiguration
.
Beware that creating a background session alone is not enough. You also need to provide a lot more plumbing to:
- Handle the app suspension in an app delegate object.
- Recreate the session when the app is terminated.
Creating download tasks and receiving progress updates
To download the mp3 files for the podcast episodes, we need to create an instance of URLSessionDownloadTask
for each download.
But here, we find a bump in our road.
At first, it looks like the download(from:delegate:)
method of URLSession
is what we need.
This method creates a URLSessionDownloadTask
instance behind the scenes to handle a file download. It also returns the result asynchronously using async-await.
Unfortunately, this method does not work if you want to track the progress of a download.
The optional delegate parameter must only conform to URLSessionTaskDelegate
, but that protocol does not have methods that report the download progress.
What we need, instead, is the urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)
method of URLSessionDownloadDelegate
.
This is only available when we explicitly create a download task using the downloadTask(with:)
method of URLSession
.
The bytes(from:delegate:)
async method of URLSession
might look like an alternative, but it’s not.
Firstly, it returns raw bytes asynchronously that we have to assemble and save to disk. So, while in theory, this allows us to monitor the progress of a download, it also introduces more complexity than is necessary.
Moreover, this method uses a data task and not a download task. This does not work when you want to handle downloads in the background when your app is suspended.
Receiving delegate callbacks from a download task
Now we have to decide which object will be the delegate of each download task.
We could make the view model the delegate of all download tasks. Then, when implementing the delegate methods, we can find the appropriate episode and update its progress.
While that can work, it quickly becomes messy when you also want to be able to pause, resume, and cancel each download task individually.
It is better to create separate download objects that will act as delegates for the respective download tasks.
class Download: NSObject {
let url: URL
let downloadSession: URLSession
private lazy var task: URLSessionDownloadTask = {
let task = downloadSession.downloadTask(with: url)
task.delegate = self
return task
}()
init(url: URL, downloadSession: URLSession) {
self.url = url
self.downloadSession = downloadSession
}
var isDownloading: Bool {
task.state == .running
}
func pause() {
task.suspend()
}
func resume() {
task.resume()
}
}
Our Download
class now needs to respond to two delegate methods of the URLSessionDownloadDelegate
protocol:
urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:)
, which reports the progress of the download.urlSession(_:downloadTask:didFinishDownloadingTo:)
, which reports the end of a download and provides the URL of the temporary file.
Transforming delegate callbacks into an asynchronous sequence of events
But how does our download class communicate with the view model?
In SwiftUI, there seems to be the wrong assumption that every object nested inside a class conforming to ObservableObject
should also be an observable object stored in an @Published
property.
But that’s just a fallacy that leads to all sorts of problems. The fact that objects connected to SwiftUI views must publish their properties does not imply that nested objects must do the same.
The @Published
property wrapper is nothing more than an asynchronous callback mechanism. This becomes clearer when you think that it’s implemented using Combine, which, according to Apple’s documentation, is a framework to “Customize handling of asynchronous events”.
Once you realize that, there are plenty of other callback mechanisms you can use:
- Delegation, like with
URLSessionDownloadTask
methods. - Callback closures, which are extensively used by many
URLSession
methods. - Explicit Combine code instead of relying on the
@Published
property wrapper. - Swift concurrency, which was introduced in Swift to supersede all other callback mechanisms.
Delivering a sequence of asynchronous events with the AsyncStream type
The Swift concurrency route is the one we will use for two main reasons:
- It is the latest and thus preferred method of handling asynchronous callbacks in Swift (this is arguably subjective, but it’s a sentiment shared by many developers).
- It keeps the code in the view model consistent since the
fetchPodcast()
method already uses async-await.
The key to turning delegate callbacks into asynchronous Swift code is to realize that the URLSessionDownloadTask
methods deliver a sequence of asynchronous events. These are a series of progress updates ending with the URL of the downloaded file.
extension Download {
enum Event {
case progress(currentBytes: Int64, totalBytes: Int64)
case success(url: URL)
}
}
We can deliver these events as an AsyncSequence
using the AsyncStream
type.
class Download: NSObject {
let url: URL
let downloadSession: URLSession
private var continuation: AsyncStream.Continuation?
private lazy var task: URLSessionDownloadTask = {
let task = downloadSession.downloadTask(with: url)
task.delegate = self
return task
}()
init(url: URL, downloadSession: URLSession) {
self.url = url
self.downloadSession = downloadSession
}
var isDownloading: Bool {
task.state == .running
}
var events: AsyncStream {
AsyncStream { continuation in
self.continuation = continuation
task.resume()
continuation.onTermination = { @Sendable [weak self] _ in
self?.task.cancel()
}
}
}
func pause() {
task.suspend()
}
func resume() {
task.resume()
}
}
The onTermination
sendable closure will cancel the download task when the caller stops listening to the stream of events.
Keeping the continuation in a stored property allows us to call it every time a delegate method is executed, delivering the asynchronous events to the caller.
extension Download: URLSessionDownloadDelegate {
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
continuation?.yield(
.progress(
currentBytes: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite))
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
continuation?.yield(.success(url: location))
continuation?.finish()
}
}
Processing an asynchronous sequence of events in the view model
We can now process the asynchronous events in the view model, updating the progress of each podcast episode as a download progresses.
To do this, we first need to decide how to save each downloaded file. We can create a directory for the podcast containing all its episode files, which we can name using the ID of the respective episode.
extension Podcast {
var directoryURL: URL {
URL.documentsDirectory
.appending(path: "\(id)", directoryHint: .isDirectory)
}
}
extension Episode {
var fileURL: URL {
URL.documentsDirectory
.appending(path: "\(podcastID)")
.appending(path: "\(id)")
.appendingPathExtension("mp3")
}
}
As asynchronous events come, we need a way to retrieve and update the correct episode. We can use a subscript in the Podcast
type for that.
struct Podcast {
let id: Int
let title: String
let artist: String
let imageURL: URL
var episodes: [Episode]
subscript(episodeID: Episode.ID) -> Episode? {
get {
episodes.first { $0.id == episodeID }
}
set {
guard let newValue,
let index = episodes.firstIndex(where: { $0.id == episodeID })
else { return }
episodes[index] = newValue
}
}
}
Then, we can add a couple of helper methods to process the asynchronous events, update the progress of each podcast episode, and save the file at the end of a download.
private extension ViewModel {
func process(_ event: Download.Event, for episode: Episode) {
switch event {
case let .progress(current, total):
podcast?[episode.id]?.update(currentBytes: current, totalBytes: total)
case let .success(url):
saveFile(for: episode, at: url)
}
}
func saveFile(for episode: Episode, at url: URL) {
guard let directoryURL = podcast?.directoryURL else { return }
let filemanager = FileManager.default
if !filemanager.fileExists(atPath: directoryURL.path()) {
try? filemanager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
}
try? filemanager.moveItem(at: url, to: episode.fileURL)
}
}
With these, we can create an asynchronous method that starts a download by creating a Download
object for an episode and processes its events using a for await
loop.
class ViewModel: NSObject, ObservableObject {
@Published var podcast: Podcast?
private var downloads: [URL: Download] = [:]
private lazy var downloadSession: URLSession = {
let configuration = URLSessionConfiguration.default
return URLSession(configuration: configuration, delegate: nil, delegateQueue: .main)
}()
@MainActor
func fetchPodcast() async throws {
let url = URL(string: "https://itunes.apple.com/lookup?id=1386867488&media=podcast&entity=podcastEpisode&limit=5")!
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
podcast = try decoder.decode(Podcast.self, from: data)
}
@MainActor
func download(_ episode: Episode) async throws {
guard downloads[episode.url] == nil else { return }
let download = Download(url: episode.url, downloadSession: downloadSession)
downloads[episode.url] = download
podcast?[episode.id]?.isDownloading = true
for await event in download.events {
process(event, for: episode)
}
downloads[episode.url] = nil
}
func pauseDownload(for episode: Episode) {
downloads[episode.url]?.pause()
podcast?[episode.id]?.isDownloading = false
}
func resumeDownload(for episode: Episode) {
downloads[episode.url]?.resume()
podcast?[episode.id]?.isDownloading = true
}
}
There is no need to have any nested observable object. Whenever an asynchronous event happens, the view model updates the episodes in the podcast
published property. This causes a refresh of the connected SwiftUI view hierarchy.
Chapter 4
Building the SwiftUI user interface to manage the data downloads
When you use URLSession
to download data from the internet, most of the work happens in the lower layers of an app’s architecture.
We have already covered this above. Now, I will highlight a few guidelines for SwiftUI.
- Providing static data to Xcode previews in a networked app
- Using the AsyncImage view to display remote images using the shared session
- Updating the rows of a list as a download progresses and responding to user actions
- Starting, pausing, and resuming view model downloads in a root view
Providing static data to Xcode previews in a networked app
We haven’t built any user interface yet, but we know we will need to preview our SwiftUI code in the Xcode preview canvas.
You might be tempted to use live data in your previews when you work with network data. This works perfectly fine in the Xcode live previews, but I am not a big fan of this approach. Why?
Firstly, I don’t want my previews to depend on my internet connection.
Despite living in an always-connected world, I still find myself disconnected from the internet, and I want my previews to keep working.
Also, if the remote server is unreachable for any reason, I cannot know whether my previews are broken because of my code or factors I cannot control.
For these reasons, I always include the preview data in my Xcode project. In this case, I saved the JSON result data in a Lookup.json file and then added a PreviewData.swift file with the following code:
extension Podcast {
static var preview: Podcast {
let url = Bundle.main.url(forResource: "Lookup", withExtension: "json")!
let data = try! Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try! decoder.decode(Podcast.self, from: data)
}
}
extension [Episode] {
static var preview: [Episode] {
Podcast.preview.episodes
}
}
extension Episode {
static var preview: Episode {
var episode = [Episode].preview[0]
episode.update(currentBytes: 25, totalBytes: 100)
return episode
}
}
Using the AsyncImage view to display remote images using the shared session
The first piece of user interface we will implement is the header at the top of the podcast view, showing the podcast image, title, and artist.
struct Header: View {
let podcast: Podcast?
var body: some View {
VStack(spacing: 8.0) {
AsyncImage(url: podcast?.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(16.0)
} placeholder: {
ProgressView()
}
.frame(width: 140.0, height: 140.0)
Text(podcast?.title ?? "Podcast Title")
.font(.largeTitle)
.bold()
Text(podcast?.artist ?? "Podcast Artist")
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.bottom)
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
.redacted(reason: podcast == nil ? .placeholder : [])
}
}
You will often need to display downloaded images in your apps. For that, SwiftUI has the convenient AsyncImage
view.
The AsyncImage
initializer takes a URL and then uses the shared URLSession
instance to download the image (probably using a data task, but Apple’s documentation does not say).
It is a very convenient type, but it has its limitations.
The first relates to the limitations of a shared session discussed above. Most of these are not a problem when displaying images in the UI. But since your ability to perform authentication is limited, you might not be able to display images requiring authentication.
The other problem is that AsyncImage
does not give you access to the downloaded data. While the shared session handles basic caching, if you want to save the image on disk, you must download it explicitly using a data or a download task.
Updating the rows of a list as a download progresses and responding to user actions
The’ Episode’ model type encapsulates all the information about a podcast episode, including its download status and progress. This is all we need for the rows of the List
.
struct EpisodeRow: View {
let episode: Episode?
var body: some View {
// ...
}
}
private extension EpisodeRow {
var details: String? {
guard let episode else { return nil }
return episode.date.formatted(date: .long, time: .omitted)
+ " - " + episode.duration.formatted()
}
var progress: Double {
episode?.progress ?? 0.0
}
var buttonImageName: String {
switch (progress, episode?.isDownloading ?? false) {
case (1.0, _): return "checkmark.circle.fill"
case (_, true): return "pause.fill"
default: return "tray.and.arrow.down"
}
}
}
I made the episode
property optional to display a few empty rows using the redacted(reason:)
view modifier while the app fetches the podcast data from the iTunes Search API.
I also moved some code into a few private computed properties to keep the code in the body
of the view more readable. One example is the code that decides the icon on the row’s button.
struct EpisodeRow: View {
let episode: Episode?
var body: some View {
HStack(alignment: .top, spacing: 16.0) {
Button(action: {}) {
Image(systemName: buttonImageName)
.font(.title3)
.frame(width: 24.0, height: 32.0)
}
.buttonStyle(.borderedProminent)
VStack(alignment: .leading, spacing: 6.0) {
Text(episode?.title ?? "Episode Title")
.font(.headline)
Text(details ?? "Episode Details")
.font(.subheadline)
.foregroundColor(.secondary)
if progress > 0 && progress < 1.0 {
ProgressView(value: progress)
}
}
}
.padding(.top, 8.0)
.padding(.bottom, 4.0)
.redacted(reason: episode == nil ? .placeholder : [])
}
}
Keep in mind that it is not necessary to pass an entire Episode
value to a row. Doing so makes a SwiftUI view tightly coupled to a model type and less reusable. But for such a simple app, it will do.
Passing observable objects to views deep down in the view hierarchy, especially a view model, is also something to avoid.
Our EpisodeRow
view does not need to be directly connected to the view model to start or pause a download when the user taps its button. It can simply provide an action closure instead, which will be configured by the root view.
struct EpisodeRow: View {
let episode: Episode?
let onButtonPressed: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 16.0) {
Button(action: onButtonPressed) {
Image(systemName: buttonImageName)
.font(.title3)
.frame(width: 24.0, height: 32.0)
}
.buttonStyle(.borderedProminent)
VStack(alignment: .leading, spacing: 6.0) {
Text(episode?.title ?? "Episode Title")
.font(.headline)
Text(details ?? "Episode Details")
.font(.subheadline)
.foregroundColor(.secondary)
if progress > 0 && progress < 1.0 {
ProgressView(value: progress)
}
}
}
.padding(.top, 8.0)
.padding(.bottom, 4.0)
.redacted(reason: episode == nil ? .placeholder : [])
}
}
struct Episode_Previews: PreviewProvider {
static var previews: some View {
List {
EpisodeRow(episode: .preview, onButtonPressed: {})
EpisodeRow(episode: nil, onButtonPressed: {})
}
.listStyle(.plain)
}
}
Starting, pausing, and resuming view model downloads in a root view
The interaction between the view model and other views only happens at the level of the root view. It is responsible for appropriately interpreting the user input.
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
// ...
}
}
private extension ContentView {
func toggleDownload(for episode: Episode) {
if episode.isDownloading {
viewModel.pauseDownload(for: episode)
} else {
if episode.progress > 0 {
viewModel.resumeDownload(for: episode)
} else {
Task { try? await viewModel.download(episode) }
}
}
}
}
The toggleDownload(for:)
method:
- Pauses any download in progress.
- Resumes paused downloads.
- Starts the download of new episodes.
We can construct the body
of the ContentView
with the Header
and the EpisodeRow
views we built above. To do this, we pass the data from the view model and use the toggleDownload(for:)
method in the action closure of the EpisodeRow
view.
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
List {
Header(podcast: viewModel.podcast)
if let podcast = viewModel.podcast {
ForEach(podcast.episodes) { episode in
EpisodeRow(episode: episode) {
toggleDownload(for: episode)
}
}
} else {
ForEach(0..<5) { _ in
EpisodeRow(episode: nil, onButtonPressed: {})
}
}
}
.listStyle(.plain)
.task { try? await viewModel.fetchPodcast() }
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
NavigationStack {
ContentView()
}
}
}
For the sake of simplicity, I put all the code in the body
of the ContentView
. Usually, I would put all the layout code into a pure view, leaving only the plumbing code that makes decisions in the root view.
Conclusions
While URLSession
is the central class of the URL Loading System, it’s not the only type you need to use to download data from the internet.
Picking the proper session configuration is also just the first step. Most of your code must manage the downloads and their callbacks, and decode or save the downloaded data.
These steps require you to make several architectural decisions in a SwiftUI app to manage network requests in the lower layers and display data while separating views from networking code.
To learn more about app architecture in SwiftUI apps, download my free guide below.
Architecting SwiftUI apps with MVC and MVVM
It’s easy to make an app by throwing some code together. But without best practices and robust architecture, you soon end up with unmanageable spaghetti code. In this guide I'll show you how to properly structure SwiftUI apps.
Matteo has been developing apps for iOS since 2008. He has been teaching iOS development best practices to hundreds of students since 2015 and he is the developer of Vulcan, a macOS app to generate SwiftUI code. Before that he was a freelance iOS developer for small and big clients, including TomTom, Squla, Siilo, and Layar. Matteo got a master’s degree in computer science and computational logic at the University of Turin. In his spare time he dances and teaches tango.