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 GUIDETable 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 GUIDECreating 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 GUIDERemoving 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 GUIDEProviding 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.
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.