Downloading files from URLs in Swift [SwiftUI Architecture]

Downloading files in Swift can be pretty straightforward, thanks to URLSession and URLSessionDownloadTask.

Moreover, tracking the progress of a download can be easily achieved with URLSessionDownloadDelegate.

However, SwiftUI apps often need to track the download progress of multiple files, complicating their networking architecture.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Table of contents

Chapter 1

Downloading files and reporting progress

In this chapter:

How to download a file from a URL in Swift

To download a file from a URL in Swift, call the download(from delegate:) asynchronous method of URLSession.

The delegate parameter is optional. You do not need to provide a delegate if you only want to download a file.

import Foundation

let url = URL(string: "https://example.com/file.mp3")!
let (downloadURL, _) = try await URLSession.shared.download(from: url)
let destinationURL = URL.downloadsDirectory
	.appending(path: "file")
	.appendingPathExtension("mp3")
try FileManager.default.moveItem(at: downloadURL, to: destinationURL)

Unlike the data(from:delegate:) method, which returns the downloaded data to the app in memory, the download(from delegate:) method saves the downloaded data directly to the local filesystem.

After the download finishes, the method returns a tuple containing a URL pointing to the temporary location where the file was saved and a URLResponse object containing the response metadata.

You then use the Filemanager class to move the file to an appropriate location, such as an app’s documents or download directory.

How to get the progress of a download

The download(from delegate:) of URLSession downloads a file asynchronously. That means your code is suspended at the call point, and execution resumes only when the file download is completed.

To get the download’s progress, you must provide a delegate conforming to the URLSessionDownloadDelegate protocol.

class Downloader: NSObject, URLSessionDownloadDelegate {
	var progress: Double = 0
	
	func downloadFile(from url: URL) async throws {
		let (downloadURL, response) = try await URLSession.shared.download(
			from: url,
			delegate: self
		)
		let destinationURL = URL.downloadsDirectory
			.appending(path: "download")
			.appendingPathExtension("mp3")
		try FileManager.default.moveItem(at: downloadURL, to: destinationURL)
	}
}

Since the URLSessionDownloadDelegate is an Objective-C protocol, the delegate class must descend from NSObject.

Because of strict concurrency checking in Swift 6, the Downloader class must conform to Sendable or isolate its state using an actor. The simplest way to achieve this is to add the @MainActor attribute to the class.

The download task reports the download progress to the delegate through the urlSession(_:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:) method.

@MainActor
class Downloader: NSObject, URLSessionDownloadDelegate {
	var progress: Double = 0

	func downloadFile(from url: URL) async throws {
		// ...
	}

	nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
		Task {
			await MainActor.run {
				progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
			}
		}
	}

	nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
		// Intentionally left empty
	}
}
Note

The urlSession(_:downloadTask:didFinishDownloadingTo:) method is required by the URLSessionDownloadDelegate protocol.

However, when you use the download(from delegate:) asynchronous method of URLSession, it already returns the download location, so you can leave the  urlSession(_:downloadTask:didFinishDownloadingTo:) method implementation empty.

You should not download a file byte by byte unless necessary

The bytes(from:delegate:) async method of URLSession might look like an alternative way to get progress data during a download, but it is not.

Since it returns raw bytes asynchronously, in theory, it allows you to monitor the progress of a download.

However, it also introduces more complexity than necessary since it requires assembling and saving the data to disk. Moreover, processing each byte individually is hugely inefficient.

Chapter 2

Networking architecture for multiple file downloads

In this chapter:

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Creating a SwiftUI app to download podcast episodes from the iTunes Search API

Downloading a single file in Swift is pretty straightforward. However, most apps must download multiple files simultaneously while displaying their progress to the user.

Such an app requires a more complicated architecture. Throughout the rest of the article, we will build an example podcast app to understand how it should be structured. You can download the Xcode project on GitHub.

We will use Apple’s iTunes Search API, which allows you to search for content within the iTunes Store. The same information from the above link is available in this archived guide in a more readable format.

In the iTunes API, you search for content by adding query parameters to the search URL. You can read my article on URL components if you need to construct a URL.

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.

Note

This article will mostly show code from the bottom up for didactic purposes, starting with model types and progressing through the view model, ending with SwiftUI views.

However, that is not how I wrote the code or how I approach architecture design in general. Instead, I always proceeded top-down, following the dependency inversion and the interface segregation principles.

Decoding the JSON data returned by a data task

The API returns the search results in JSON format.

{
	"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"
		}
}

I have simplified the JSON data above, removing the members we don’t need in our app.

The first object in the results array contains the podcast information. All the objects after that are podcast episodes. The JSON data above only includes one, but the complete response contains five because of the URL’s limit=5 query parameter.

Parsing the JSON data for a podcast episode is straightforward.

Episode.swift

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

Decoding the podcast information is not as straightforward since its data is in the same results array as the episodes.

In our app, placing the episodes inside a Podcast type makes more sense.

Podcast.swift

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

Here, we use a nested unkeyed container to get the contents of the results array. Then, we used the values in the first JSON object to populate the properties of the Podcast type and decode all the other objects as Episode values.

Managing file downloads with separate delegate objects

Networking code usually goes inside view models following the MVVM pattern for SwiftUI apps. In our simple app, that’s all we need.

However, remember that a shared MVC controller is often required in apps with complex navigation or requiring authentication.

We start by fetching the podcast data from the API.

JSONDecoder+iTunes.swift

extension JSONDecoder {
	static let iTunes: JSONDecoder = {
		let decoder = JSONDecoder()
		decoder.dateDecodingStrategy = .iso8601
		return decoder
	}()
}

ViewModel.swift

@Observable @MainActor
class ViewModel {
	var podcast: Podcast?

	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)
		podcast = try JSONDecoder.iTunes.decode(Podcast.self, from: data)
	}
}

To download the mp3 files for the podcast episodes, we need to create an instance of URLSessionDownloadTask for each download.

We could do so directly in the view model, but making a single object the delegate for all the download tasks would not be a good choice.

While each delegate method call provides the original URLSessionDownloadTask in its parameters, discerning the task in each callback to update the progress of the corresponding download introduces too much complexity.

Creating separate download objects that will act as delegates for each download task is better.

Download.swift

final class Download: NSObject {
	private let task: URLSessionDownloadTask

	convenience init(url: URL) {
		self.init(task: URLSession.shared.downloadTask(with: url))
	}

	convenience init(resumeData data: Data) {
		self.init(task: URLSession.shared.downloadTask(withResumeData: data))
	}

	private init(task: URLSessionDownloadTask) {
		self.task = task
	}

	func start() {
		task.delegate = self
		task.resume()
	}
}

The init(resumeData:) initializer will allow us to resume a canceled download from when it was interrupted.

Note

Our app will use the shared session configuration for all downloads. However, if you want your downloads to continue in the background when your app is suspended, you must use a background session.

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.
Chapter 3

Monitoring the download progress with Swift Concurrency

In this chapter:

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Removing the need of nested observable objects

Each instance of our Download class must report its progress to the view model. We have several mechanisms to choose from.

The wrong assumption seems to be that, in SwiftUI, every object nested inside an @Observable class should also be observable.

However, that is not our only solution, even though the Observation framework solves the problem of nested observable objects outlined in the linked article.

The @Observable macro is nothing more than an asynchronous callback mechanism. This becomes clearer when you expand it to reveal its underlying  Combine implementation.

There are plenty of other callback mechanisms we can use:

  • Delegation, used by URLSessionDownloadTask.
  • Callback closures, used by many old URLSession methods.
  • Swift concurrency, introduced to supersede all other callback mechanisms.

Transforming delegate callbacks into an asynchronous stream of events

We will follow the Swift concurrency route for two 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.

We will use AsyncStream to turn delegate callbacks into an asynchronous sequence of events reporting the download progress and ending with the URL of the downloaded file.

Download.swift

final class Download: NSObject {
	let events: AsyncStream
	private let continuation: AsyncStream.Continuation
	private let task: URLSessionDownloadTask

	enum Event {
		case progress(currentBytes: Int64, totalBytes: Int64)
		case completed(url: URL)
		case canceled(data: Data?)
	}

	convenience init(url: URL) {
		self.init(task: URLSession.shared.downloadTask(with: url))
	}

	convenience init(resumeData data: Data) {
		self.init(task: URLSession.shared.downloadTask(withResumeData: data))
	}

	private init(task: URLSessionDownloadTask) {
		self.task = task
		(self.events, self.continuation) = AsyncStream.makeStream(of: Event.self)
		super.init()
		continuation.onTermination = { @Sendable [weak self] _ in
			self?.cancel()
		}
	}

	func start() {
		task.delegate = self
		task.resume()
	}

	func cancel() {
		task.cancel { data in
			self.continuation.yield(.canceled(data: data))
			self.continuation.finish()
		}
	}
}

The onTermination sendable closure on the continuation 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.

Download.swift

extension Download: URLSessionDownloadDelegate {
	func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
		continuation.yield(.completed(url: location))
		continuation.finish()
	}

	func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
		continuation.yield(
			.progress(
				currentBytes: totalBytesWritten,
				totalBytes: totalBytesExpectedToWrite))
	}
}

Storing the download status and progress of a file in a single source of truth

Having implemented file downloading and progress reporting, we must now find a place to store this information and display it later in our user interface.

This choice depends again on your app’s architecture. Our app will place the progress information inside the Episode model type.

Episode.swift

struct Episode: Identifiable {
	let id: Int
	let podcastID: Int
	let duration: Duration
	let title: String
	let date: Date
	let url: URL
	var state: State = .idle
	private(set) var currentBytes: Int64 = 0
	private(set) var totalBytes: Int64 = 0

	enum State: Equatable {
		case idle
		case dowloading
		case completed
		case canceled(resumeData: Data)
	}

	var progress: Double {
		guard totalBytes > 0 else { return 0 }
		return Double(currentBytes) / Double(totalBytes)
	}

	var isDownloadCompleted: Bool {
		currentBytes == totalBytes && totalBytes > 0
	}

	mutating func update(currentBytes: Int64, totalBytes: Int64) {
		self.currentBytes = currentBytes
		self.totalBytes = totalBytes
	}
}

There are several reasons for this choice.

In SwiftUI, each piece of information should be preserved in a single source of truth. Moreover, keeping an episode’s data and download progress together makes sense since these pieces of information are related, resulting in highly cohesive code.

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.

Moreover, you can effortlessly encode all this information using the Encodable protocol and store it on disk to resume downloading when the app is relaunched after terminating.

Consuming an asynchronous stream of progress events in the view model

We can now consume the asynchronous sequence of events in the view model, updating the progress of each podcast episode as the respective download progresses.

We first need to decide how to save each file at the end of a download. We will create a directory for the podcast containing all its episode files, which we can name using the ID of the respective episode.

Podcast.swift

extension Podcast {
	var directoryURL: URL {
		URL.documentsDirectory
			.appending(path: "\(id)", directoryHint: .isDirectory)
	}
}

Episode.swift

extension Episode {
	var fileURL: URL {
		URL.documentsDirectory
			.appending(path: "\(podcastID)")
			.appending(path: "\(id)")
			.appendingPathExtension("mp3")
	}
}

As asynchronous events occur, we need to retrieve and update the correct episode. To do that, we can add a subscript to the Podcast type.

Podcast.swift

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 index = episodes.firstIndex(where: { $0.id == episodeID }) else { return }
			episodes[index] = newValue
		}
	}
}

Then, we can add a couple of helper methods to our view model to process the asynchronous events, update the progress of each podcast episode, and save the file at the end of a download.

ViewModel.swift

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 .completed(url):
				saveFile(for: episode, at: url)
				podcast?[episode.id].state = .completed(fileURL: url)
			case let .canceled(data):
				podcast?[episode.id].state = if let data {
					.canceled(resumeData: data)
				} else {
					.notDownloaded
				}
		}
	}

	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 pieces in place, 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.

ViewModel.swift

@Observable @MainActor
class ViewModel {
	var podcast: Podcast?
	private var downloads: [URL: Download] = [:]

	func fetchPodcast() async throws {
		// ...
	}

	func download(_ episode: Episode) async throws {
		guard downloads[episode.url] == nil,
			  !episode.isDownloadCompleted
		else { return }
		let download = if case let .canceled(data) = episode.state {
			Download(resumeData: data)
		} else {
			Download(url: episode.url)
		}
		downloads[episode.url] = download
		download.start()
		podcast?[episode.id].state = .dowloading
		for await event in download.events {
			process(event, for: episode)
		}
		downloads[episode.url] = nil
	}

	func cancelDownload(for episode: Episode) {
		downloads[episode.url]?.cancel()
		podcast?[episode.id].state = .idle
	}
}

There is no need for a nested observable object. Whenever an asynchronous event occurs, the view model updates the episodes in the podcast stored property, which will refresh any connected SwiftUI view hierarchy.

Chapter 4

Building the SwiftUI user interface to manage the file downloads

In this chapter:

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices

MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.

DOWNLOAD THE FREE GUIDE

Providing static data to Xcode previews in a networked app

We haven’t built any user interface yet, but we know we will likely need to preview our SwiftUI code in the Xcode preview canvas.

You might be tempted to use live data in your previews when working with network data. This works perfectly fine in the Xcode live previews, but I am not a big fan of this approach.

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 or down, I cannot know whether my previews are broken because of my code or factors I cannot control.

I always include the preview data in my Xcode project for these reasons. In this case, I saved the JSON result data in a Lookup.json file.

PreviewData.swift

extension Podcast {
	static let preview: Podcast = {
		let url = Bundle.main.url(forResource: "Lookup", withExtension: "json")!
		let data = try! Data(contentsOf: url)
		return try! JSONDecoder.iTunes.decode(Podcast.self, from: data)
	}()
}

extension [Episode] {
	static let preview: [Episode] = Podcast.preview.episodes
}

extension Episode {
	static let preview = [Episode].preview[0]
}

Implementing the user interface in modular views disconnected from the view model

The first user interface element we will implement is the header at the top of the podcast view, which shows the podcast image, title, and artist.

Header.swift

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)
				.font(.largeTitle)
				.bold()
			Text(podcast.artist)
				.foregroundColor(.secondary)
		}
		.frame(maxWidth: .infinity)
		.padding(.bottom)
		.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
    }
}

#Preview {
	List {
		Header(podcast: .preview)
	}
	.listStyle(.plain)
}

You will often need to display downloaded images in your apps. SwiftUI has the convenient AsyncImage view for that.

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.

EpisodeRow.swift

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)
					.font(.headline)
				Text(details ?? "Episode Details")
					.font(.subheadline)
					.foregroundColor(.secondary)
				if episode.progress > 0 && !episode.isDownloadCompleted {
					ProgressView(value: episode.progress)
				}
			}
		}
		.padding(.top, 8.0)
		.padding(.bottom, 4.0)
	}
}

private extension EpisodeRow {
	var details: String? {
		episode.date.formatted(date: .long, time: .omitted)
		+ " - "
		+ episode.duration.formatted()
	}

	var buttonImageName: String {
		switch (episode.isDownloadCompleted, episode.state) {
			case (true, _): return "checkmark.circle.fill"
			case (false, .dowloading): return "pause.fill"
			case (false, _): return "tray.and.arrow.down"
		}
	}
}

#Preview {
	List {
		EpisodeRow(episode: .preview, onButtonPressed: {})
	}
	.listStyle(.plain)
}

I placed some code into private computed properties to make the code in the view’s body more readable. One example is the code that decides the icon on the row’s button.

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. Instead, it can provide an onButtonPressed action closure, which the root view will configure.

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.

ContentView.swift

struct ContentView: View {
	@State private var viewModel = ViewModel()

	var body: some View {
		// ...
	}
}

private extension ContentView {
	func toggleDownload(for episode: Episode) {
		if episode.state == .dowloading {
			viewModel.cancelDownload(for: episode)
		} else {
			Task { try? await viewModel.download(episode) }
		}
	}
}

The toggleDownload(for:) method starts downloading the selected episode or cancels it if it’s already in progress.

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.

ContentView.swift

struct ContentView: View {
	@State private var viewModel = ViewModel()

	var body: some View {
		List {
			if let podcast = viewModel.podcast {
				Header(podcast: podcast)
				ForEach(podcast.episodes) { episode in
					EpisodeRow(episode: episode) {
						toggleDownload(for: episode)
					}
				}
			}
		}
		.listStyle(.plain)
		.task { try? await viewModel.fetchPodcast() }
	}
}

#Preview {
	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.

If you want to show placeholders while the podcast’s data loads, you can use the redacted(reason:) view modifier.

Podcast+Placeholder.swift

extension Podcast {
	public static let placeholder: Podcast = .init(
		id: 0,
		title: "xxxxxx xxxx xx",
		artist: "xxxxxx xxxx xx",
		imageURL: URL(string: "example.com/image.jpg")!,
		episodes: []
	)
}

Episode+Placeholder.swift

extension Episode {
	static var placeholder: Episode {
		Episode(
			id: 0,
			podcastID: 0,
			duration: .seconds(0),
			title: "xxxxxx xxxxx xx xxxxx",
			date: .distantPast,
			url: URL(string: "https://example.com")!
		)
	}
}

ContentView.swift

struct ContentView: View {
	@State private var viewModel = ViewModel()

	var body: some View {
		List {
			if let podcast = viewModel.podcast {
				Header(podcast: podcast)
				ForEach(podcast.episodes) { episode in
					EpisodeRow(episode: episode) {
						toggleDownload(for: episode)
					}
				}
			} else {
				Group {
					Header(podcast: .placeholder)
					ForEach(0..<5) { _ in
						EpisodeRow(episode: .placeholder, onButtonPressed: {})
					}
				}
				.redacted(reason: .placeholder)
			}
		}
		.listStyle(.plain)
		.task { try? await viewModel.fetchPodcast() }
	}
}

SwiftUI App Architecture: Design Patterns and Best Practices

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

Leave a Comment