Downloading Data in SwiftUI with URLSession and async/await

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 NOW

Table 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?

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.

iOS Apps Transfer Data over the Internet Protocol Suite

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.

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.

Grouping the Downloads for a Web Page into a Session

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.

The URLSession Constellation of Types in the iOS URL Loading System

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 a URLSessionConfiguration 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 a URLRequest 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 a URLResponse 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.

The Subclasses of URLSessionTask

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 as POST and PUT.
  • 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, probably URLSessionDataTask.
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

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 example podcast app we will develop in this article

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 REST API calls.

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

Managing Callbacks using Different Delegates for each URLDownloadTask

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

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 : [])
    }
}

The Xcode preview of the podcast header

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

The Xcode preview of the podcast list rows

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

The Xcode preview of the entire podcast view

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.
[sponsored]

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.

GET THE FREE BOOK NOW

2 thoughts on “Downloading Data in SwiftUI with URLSession and async/await”

  1. I really love this approach, it’s clean and elegant. But when testing the process for moving the downloaded file, I realised there’s a bug in the podcast app, I wrote it up on the repo: https://github.com/matteom/Podcast/issues/1

    Basically continuations aren’t suitable because you have to move the downloaded function out from the temporary location before the delegate function returns, or else it’s deleted.

    Would love to know of a solution to, will write back if I find one. For now I added a `var handleCompletedFile: ((URL) -> Void)?` to `Download`.

    Reply

Leave a Comment