Uploaded by divyant srivastava

SwiftUI App Architecture: Design Patterns & Best Practices

advertisement
SwiftUI App Architecture: Design Patterns and Best Practices
Page 1 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
A Small Promise
3
Who is this guide for?
4
The Blueprint of iOS Apps
5
Why do we need design patterns?
5
The vanilla MVC pattern and its iOS evolution
6
MVVM is just MVC with some obsolete differences
9
The hidden flaws of the MV pattern
12
Managing data and business logic in model types
16
Representing the app's data
17
Implementing the domain business logic in the model layer
19
Encapsulating state and storage in controllers
24
Storing data and keeping the app's logic isolated inside controllers
24
Keeping the app’s state together with its business logic
26
Displaying information and enabling interaction in views
30
Organizing view code into small modular types
31
Keeping views separated from the app business logic
39
Bringing the whole app together in the root layer
49
Managing the app’s navigation structure with architectural views and modal presentation
49
Mapping user action into the app’s business logic
53
Please Share this Guide
58
Page 2 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
A Small Promise
It took me weeks of research and many hours of work to create this guide. But I
am happy to offer it to you completely free of charge.
In exchange, I ask you for a small promise.
If you think I did a good job, please share this with someone who can benefit
using this link:
matteomanferdini.com/swiftui-app-architecture
That way, I can spend less time looking for people I can help and more
producing great free material.
Thank you for reading.
Let’s start.
Page 3 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Who is this guide for?
I wrote this guide for both beginners and experienced developers.
If you are just starting to make SwiftUI apps, most of your questions
probably revolve around how to structure your code.
You might already know some of the features of SwiftUI and the iOS SDK, but
you can't bring them all together in a well-structured app. So, you are going to
get the most value out of this guide.
Even if you already have some experience, you can gain much value from this
guide.
Online articles, books, and courses rarely approach SwiftUI development from a
structural point of view. App architecture is always an afterthought, and many
developers get bad habits. This guide will correct those.
I have been teaching the concepts in this guide for years and updated them for
SwiftUI apps. I have noticed that many experienced developers don't know
them. At the beginning of my career, I didn't know them either and made most
of the mistakes I now see in many apps.
Page 4 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
The Blueprint of iOS Apps
Why do we need design patterns?
Whether building an entirely new app or adding features to an existing one, the
question is always the same: how should you structure your code?
When you learn to program in Swift, you get many tools: functions, structures,
enumerations, classes, protocols, etc. What you don't learn, though, is how to
use them and for which purpose.
There are many tasks you need to consider when building an iOS app. The
most common ones, which you find in pretty much any app, are:
• Representing data.
• Building the user interface.
• Implementing the app's logic.
• Storing data on the disk.
• Downloading data from the Internet.
• Reading data from the device sensors.
There are many ways to address these points using the Swift language’s
constructs, but that does not mean every approach is correct.
My favorite easy-to-understand metaphor is building a house. While there are
different approaches, all houses have the same structure: foundations, walls,
doors, windows, and a roof.
Page 5 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Whether a house was built yesterday or 2.000 years ago, its blueprint remains
the same because it’s the only one that makes sense. If you deviate, the house
will fall apart, or nobody will want to live in it.
Some developers say your app’s architecture depends on its specifics. I
disagree and think that “it depends” is one of the worst answers developers
often give. While you can structure your code however you want, only a specific
set of predefined blueprints makes sense.
In software development, these blueprints are called design patterns. They
emerged because developers created standard solutions that work for any
software. They did the work for you, so you don't need to go through the same
process of trial and error.
Online, you find several design patterns with different esoteric names.
Following a pattern is better than not following one. But, if you look at them
closely, you will notice they all have the same structure, like houses.
That's not a coincidence. The most popular pattern is the Model-ViewController, or MVC, the father of all design patterns.
So, that’s where we will start.
The vanilla MVC pattern and its iOS evolution
The MVC pattern divides the structure of any app into three layers, each with
precise responsibilities:
• The model layer represents the app’s data and encapsulates the
domain business logic.
• The view layer is the app's user interface. It shows data to the user and
allows interaction.
Page 6 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
• The controller layer bridges the other two layers and implements the
app’s business logic.
(I'll explain later the difference between the domain business logic and the
app's business logic).
In one paragraph, I gave you a blueprint you can follow to build any app.
You will find many different arrow configurations online, but they are less
important than people think. Don't worry about the details for now. We will
spend the rest of this guide exploring code.
What is important is that now you know where each piece of code goes. These
roles will guide every structural decision you make.
Page 7 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
The diagram above shows the “vanilla” version of the MVC pattern. Developers
have used it for decades and on many platforms, so it’s not surprising that it
works for SwiftUI, too.
However, we don’t need to stick to the vanilla version of MVC. We can expand
the pattern, as it has historically happened on Apple’s platforms and others.
iOS apps are made of many "screens" through which the user can move. This
requires some code to handle navigation and map the user’s actions into the
app’s business logic.
As your app grows, it becomes clear that such code tends to converge towards
the meeting point of the view and controller layers.
That’s where we can add an extra layer to take on those responsibilities, avoid
large intricate code, and keep our app well-structured.
In UIKit, that was the role of view controllers. In SwiftUI, that code does not
magically disappear, so we need a similar layer. I named it the root layer
because it comprises views that sit at the root of the view hierarchy for an entire
screen.
Page 8 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
The reason why the arrows follow those directions will be more evident when
we look at the code for our app. For now, what is essential is the four layers and
their responsibilities.
Note
To my knowledge, I am the only one calling this extra layer the root layer, so
you won’t find the concept anywhere else. However, I’ll show you a similar
concept in the MV pattern below.
MVVM is just MVC with some obsolete differences
Before we see how to build a SwiftUI app following the MVC blueprint, I have
to discuss two other prominent (at least online) design patterns in SwiftUI apps.
The first is the Model-View-ViewModel pattern or MVVM.
Page 9 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
In short, the MVC and MVVM have always been practically the same design
pattern, with a few nuanced differences. In SwiftUI, those disappear.
MVVM is a design pattern created by Microsoft architects to simplify eventdriven programming. Its diagram shows that it has the same structure as MVC.
You don’t need to be a genius to see that the view model layer of MVVM is the
controller layer of MVC.
The only difference is that the view and view model layers are connected
through a binder using declarative data binding technology. Historically, MVC
used standard imperative programming practices instead.
That is, or better, was the only difference between MVC and MVVM.
In UIKit, MVC apps used the lifecycle events of view controllers, delegation,
and callback closures to transfer data between the view and controller layers.
Page 10 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
MVVM apps, instead, relied on open-source event-driven functional reactive
programming frameworks like RxSwift.
That difference disappeared with the introduction of SwiftUI, where state is
stored inside @State properties, either as value types or as @Observed
objects. When a @State property is updated, the user interface is
automatically refreshed, removing the only difference between the MVC and
MVVM patterns.
But there is more.
UIKit apps were managed by view controllers, an unavoidable part of the
framework. So, the proponents of MVVM swept view controllers under the rug,
placing them inside the view layer and pretending they didn’t exist.
Page 11 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
This is similar to the diagram of the MVC pattern.
Controllers have not disappeared; they are called services and are part of the
model layer in MVVM. If we added them to the diagram, we would return to
the extended MVC pattern.
The hidden aws of the MV pattern
Recently, a “new” pattern has become more popular among some developers
online, even though there are online courses about it.
They call this new pattern Model-View or MV because they claim that view
models are no longer needed in SwiftUI, thus removing the last “VM” from the
pattern’s name.
The opinion of the proponents of the MV pattern is that the container view at
the top of a hierarchy functions as the view model since that's where most
stored properties with SwiftUI wrappers are located.
fl
Page 12 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
You do not have to be an expert in graph theory to see this is the same
diagram again. The view model of MVVM is now called a container, borrowing
from the React framework, and shared controllers are called aggregate models.
MV is nothing else than MVC.
As outlined above, I agree that root views have a specific role in SwiftUI apps.
MVC is a solid pattern, so I also have no objections. However, the MV pattern
is awed when we look at each layer’s responsibilities.
Due to their nature, container views in SwiftUI tend to accumulate a lot of code
with low cohesion. Moreover, their code is untestable since SwiftUI views are
tightly connected to the framework.
fl
Page 13 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
For these reasons, I follow the opposite approach. Instead of gathering
responsibilities in one layer, I separate them further, retaining the view model
layer of MVVM and adding the root layer to the pattern.
I named this pattern RootMVC since, like all other patterns, it’s a derivative of
MVC, with an emphasis on root views as the central control point.
This diagram is not prescriptive. View models or controllers should be present
only when necessary.
Page 14 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Root views can access the controller layer directly, and view models can act
directly on the model without controllers. However, in sufficiently complex
apps, both layers are probably unavoidable.
Page 15 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Managing data and business
logic in model types
We will now go through a concrete example to show you how to use the
abstract ideas of the MVC in a real app.
We will look at a simple banking app that manages multiple accounts. The user
can create new accounts and perform transactions in each account.
This is what the final app looks like.
Page 16 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
I didn't spend too much time on a pretty design because it's unnecessary. This
will also keep the code concise, letting us focus on the app's structure without
distractions.
This guide aims to teach you how to structure apps, so I won’t explain the app’s
code line by line, as I usually do in my articles and courses. Instead, I will show
you the relevant sections of the complete code, which you can download here.
Note
If you want in-depth coverage of SwiftUI, best practices, and more advanced
techniques for creating full-fledged apps with data storage and networking, I
have a course called SwiftUI Structural Foundations.
I open the course regularly only to my email list subscribers. You can join here if
you are not in it yet.
Representing the app's data
The model layer is the foundation of our app and is independent from the rest
of the app. Model types only focus on data and don't need to worry about
storage, networking, or the user interface, making them easy to write and test.
Despite their simplicity, much of the code that powers a well-structured app
resides in the model layer. A bug in the model layer can influence any or all
parts of your app.
On the other hand, a solid model layer will help you significantly reduce the
code in different layers of MVC. So, it's important to get model types right.
Our app must deal with bank accounts and transactions, so we can create some
types to handle those.
Page 17 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
struct Account {
let name: String
let iban: String
let kind: Kind
var transactions: [Transaction]
enum Kind {
case checking, savings, investment
}
}
struct Transaction {
let amount: Int
let beneficiary: String
let date: Date
}
Note
I used Int for the amount property of the Transaction type instead of
Float because floating-point numbers should never be used when precision is
required, like in financial transactions. Money is usually expressed in cents using
integers.
It’s better to use Swift's value types, i.e., structures and enumerations, as much
as possible instead of using reference types like classes.
Values are copied, so each code can deal with local values unaffected by other
code. Value types also simplify concurrency, especially after introducing Swift 6
language mode with strict concurrency checking.
Of course, objects should not be ruled out. You must use classes if you use
SwiftData for your app’s model.
Page 18 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Implementing the domain business logic in the model
layer
The most significant mistake developers make is putting the domain business
logic in the other layers of MVC.
Domain business logic is any logic that deals with the domain covered by our
app and its rules. In our case, the field is banking, and all the code that deals
with banking and its regulations belongs to the domain logic.
Many developers make their model layers too thin, using model types only to
represent the data. But in MVC, the model layer should do much more.
Model types must:
• encapsulate the domain business logic;
• transform data from one format to another.
There is much more to bank accounts and transactions than expressed in the
Account and Transaction structures above. For example:
• Our bank allows customers to open an account only with an initial
€2.000 deposit.
• The balance of an account is the sum of all its transactions.
• Each transaction is final, and it should not be possible to delete it once
executed.
Since these rules belong to the domain business logic, their code should go
into the model layer.
Page 19 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Account.swift
struct Account: Codable, Identifiable, Hashable {
let name: String
let iban: String
let kind: Kind
private(set) var transactions: [Transaction]
enum Kind: String, Codable, CaseIterable {
case checking, savings, investment
}
var id: String { iban }
var balance: Int {
var balance = 0
for transaction in transactions {
balance += transaction.amount
}
return balance
}
init(name: String, iban: String, kind: Kind) {
self.name = name
self.kind = kind
self.iban = iban
self.transactions = [
Transaction(amount: 2000_00, beneficiary: "Initial Balance",
date: Date())
]
}
mutating func add(_ transaction: Transaction) {
transactions.append(transaction)
}
}
Page 20 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Transaction.swift
struct Transaction: Identifiable, Hashable {
let id = UUID()
let amount: Int
let beneficiary: String
let date: Date
}
extension Transaction: Codable {
enum CodingKeys: CodingKey {
case id
case amount
case beneficiary
case date
}
}
Now, our model types are richer and enforce all the abovementioned rules.
The initializer of the Account structure sets the initial transaction to €2.000,
implying the customer physically deposited the sum when opening the
account.
balance is a computed property that sums all the transaction amounts.
Moreover, the transactions property is read-only and can be modified only
by the Account structure through its add(_:) method, making it impossible
for external code to delete existing transactions.
Our model types also handle data transformation. All the types conform to the
Codable protocols, which allow us to store and transfer data.
Page 21 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Note
It’s usually better to develop an app from the top down, starting from the view
layer and letting it influence the interface of model types and their
implementation.
This is reflected in our model types’ adoption of the Identifiable and
Hashable protocols, which will later be required by SwiftUI views.
This is a consequence of the dependency inversion principle. However, the
SOLID principles require experience to be correctly understood, so I cover
them only in my advanced courses.
However, you can ignore this principle if you are not there yet. Following the
MVC pattern is a low-hanging fruit and will improve the structure of your apps.
Moreover, from a didactical point of view, it’s easier to start from model types in
this guide. Developing an app bottom-up is also possible, as I am showing
here, while respecting the dependency inversion principle.
Often, the above logic ends inside controllers or, worse, views, spreading the
domain business logic across the entire app. This leads to duplication of
functionality and makes your code hard to maintain.
Page 22 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Keeping the domain business logic inside model types also simplifies testing.
And since the whole app will rely on it, this approach produces fewer bugs.
Page 23 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Encapsulating state and storage
in controllers
The controller layer is the central layer of MVC. Here, we also find many
responsibilities developers often mistakenly put inside SwiftUI views.
While the model layer contains the domain business logic, the controller layer
contains the app’s business logic, which includes:
• keeping the state for the entire app;
• storing data on disk;
• transmitting data over the Internet;
• handling events from the device sensors (GPS, accelerometer,
gyroscope, etc.)
Storing data and keeping the app's logic isolated
inside controllers
We will start by saving and reading data on disks, which many apps need. We
will use the iOS file system to save our data in the documents directory.
Page 24 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
StorageController.swift
class StorageController {
private let accountsFileURL = URL.documentsDirectory
.appendingPathComponent("Accounts")
.appendingPathExtension("json")
func save(_ accounts: [Account]) {
guard let data = try? JSONEncoder().encode(accounts) else {
return
}
try? data.write(to: accountsFileURL)
}
func fetchAccounts() -> [Account] {
guard let data = try? Data(contentsOf: accountsFileURL) else {
return []
}
let accounts = try? JSONDecoder()
.decode([Account].self, from: data)
return accounts ?? []
}
}
The first thing to notice here is that we used a class instead of a struct.
Controllers must be objects because they must be shared across the entire app
and provide a unique access point.
The save(_:) method encodes the data and stores it in a file, while
fetchAccounts() reads that file and decodes its data. Since an Account
contains its respective transactions, this is enough to save all our data.
While this class does not do much, keeping its code separated from the rest is
crucial. In a real-world app, things are rarely this simple. Most code in your app
will grow larger with time, so keep responsibilities separated from the start.
Page 25 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Keeping the app’s state together with its business
logic
Pretty much any app needs to store its global state somewhere.
Holding the app's state is another task that requires more code than might be
evident at first glance. Many developers store the data representing the current
app state in an @Observable object and call it a day.
@Observable class StateController {
var accounts: [Account] = []
}
You should be suspicious whenever you see a type with stored properties but
no code. This goes against the fundamental tenets of object-oriented
programming, where logic must be associated with data.
Recall that the controller layer must contain the app’s business logic. In our
example that includes:
• assigning an IBAN to a newly created account;
• organizing the app’s accounts;
• performing a transaction;
• saving and reading data.
If you don't put the app's logic in a controller, it will spread into your SwiftUI
views.
That would not be the correct place. These are global rules that affect the
behavior of the entire app. Putting such code inside views would lead to
duplication, polluting views with responsibilities they should not have.
Page 26 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Let’s start by generating IBANs for new accounts.
String+IBAN.swift
extension String {
static func generateIBAN() -> String {
let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
let digits = "0123456789"
return random(length: 2, from: letters)
+ random(length: 2, from: digits)
+ random(length: 4, from: letters)
+ random(length: 12, from: digits)
}
private static func random(length: Int,
from characters: String) -> String {
String((0 ..< length)
.map { _ in characters.randomElement()! })
}
}
Note
Generating an IBAN could be considered part of the model since it is related to
banking regulations. However, IBANs are not randomly generated numbers, as
we do in our simple example.
External rules must be considered in a real-world scenario. For example, a bank
must ensure that IBANs are unique.
A centralized system would carry out such a task, and an app would only have
to retrieve such data through a network request. That’s why I placed it in the
controller layer.
We can now extend the state controller to perform all the tasks I listed above.
Page 27 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
StateController.swift
@Observable class StateController {
var accounts: [Account]
subscript(iban iban: String) -> Account? {
guard let index = accounts.firstIndex(where: { $0.iban == iban })
else { return nil }
return accounts[index]
}
private let storageController = StorageController()
init() {
self.accounts = storageController.fetchAccounts()
}
func addAccount(named name: String, withKind kind: Account.Kind) {
let account = Account(name: name, iban: .generateIBAN(),
kind: kind)
accounts.append(account)
storageController.save(accounts)
}
func addTransaction(withAmount amount: Int, beneficiary: String,
to account: Account) {
guard let index = accounts.firstIndex(of: account) else {
return
}
let transaction = Transaction(amount: amount,
beneficiary: beneficiary, date: Date())
accounts[index].add(transaction)
storageController.save(accounts)
}
}
Notice how much code we have now. It would have spread across views if we
hadn’t gathered it here.
Page 28 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
The addAccount(named:withKind:) and
addTransaction(withAmount:beneficiary:to:) methods accept
parameters with simple types and create Account and Transaction values
internally. This keeps the app’s business logic hidden from views, which, in turn,
manage only the user inputs.
The other important part is that the StateController class holds the app’s
state and uses a reference to StorageController to save and read data.
For simplicity, that happens every time our data changes. This works well for
small amounts of data since it’s relatively quick. In an app with heavier data, we
might dispatch disk access to a background thread using Swift concurrency.
We build our house slowly, placing each new type on solid foundations.
Page 29 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Displaying information and
enabling interaction in views
This chapter skips the root layer and jumps to the view layer. The reason is
simple: root views are a bridge between controllers and views, so it’s easier to
implement them when the other two exist.
The view layer has only two responsibilities:
• displaying information, and
• allowing user interaction.
It’s crucial to note how few responsibilities there are in views. The usual mistake
is putting too much code inside the view layer, leading to massive views.
We relegated any considerations about the app’s navigation structure and
interaction with its data to the root layer. That simplifies our view code.
Page 30 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Let’s look again at the final result we want to achieve:
Organizing view code into small modular types
While views have few general responsibilities, their code grows quickly.
An essential part of structuring SwiftUI views is keeping them modular and
reusable. Nothing prevents you from putting the code for an entire screen
inside a single view. But that code would soon become unmanageable.
That applies to any code, not only to SwiftUI specifically. Whether you write
imperative or declarative code, extended code is complex to read and easy to
get wrong.
Page 31 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
When splitting our view code, some cases are more obvious than others.
Generally, any code that repeats more than once is a good candidate for a
view.
A simple example is the two buttons to add accounts and transactions at the
bottom of the respective screens. Since the only difference is in their title and
actions, it's clear that their code should be in a single, reusable view.
FullWidthButtonStyle.swift
struct FullWidthButtonStyle: PrimitiveButtonStyle {
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.trigger() }) {
configuration.label
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
extension PrimitiveButtonStyle where Self == FullWidthButtonStyle {
static var fullWidth: FullWidthButtonStyle {
FullWidthButtonStyle()
}
}
Page 32 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
AddButton.swift
struct AddButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(title, systemImage: "plus.circle.fill", action: action)
.buttonStyle(.fullWidth)
.controlSize(.extraLarge)
.safeAreaPadding()
}
}
#Preview {
AddButton(title: "Add item", action: {})
}
Here, we find the most common way for views to communicate top-down with
their children: stored properties. The title property of the AddButton view
has a simple String type, while the action property takes a function to pass
to the Button view.
Page 33 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Note
While it would be possible to put all the code in the example above inside the
AddButton view, it’s better and more idiomatic to separate styling from
functionality by creating the FullWidthButtonStyle structure.
Formatting data is another task repeated across screens, so its code should also
be abstracted.
Int+Cents.swift
extension Int {
var centsFormatted: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
return formatter.string(from:
NSNumber(value: Float(self) / 100 )) ?? ""
}
}
Page 34 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
String+IBAN.swift
extension String {
var ibanFormatted: String {
var remaining = Substring(self)
var chunks: [Substring] = []
while !remaining.isEmpty {
chunks.append(remaining.prefix(4))
remaining = remaining.dropFirst(4)
}
return chunks.joined(separator: " ")
}
static func generateIBAN() -> String {
// ...
}
private static func random(lenght: Int,
from characters: String) -> String {
// ...
}
}
The rows of a table are also another obvious case of reusable views. We find
one instance of that in the Accounts screen.
Previews.swift
extension Account {
static let preview = Account(
name: "Test account",
iban: .generateIBAN(),
kind: .checking
)
}
Page 35 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
AccountsView.swift
private struct Row: View {
let account: Account
var body: some View {
VStack(alignment: .leading, spacing: 4.0) {
HStack {
Text(account.name)
.font(.headline)
Spacer()
Text(account.balance.centsFormatted)
.font(.headline)
}
Text("\(account.kind.rawValue.capitalized) account")
.font(.subheadline)
.foregroundColor(.secondary)
Text(account.iban.ibanFormatted)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 8.0)
}
}
#Preview("Row") {
List {
Row(account: .preview)
}
}
Page 36 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Whenever a view is needed only inside another one, I prefer to place it in the
same file as a private type. This helps code organization, keeps types from
being used where they shouldn’t, and allows me to reuse their names.
We also have a table to show the list of transactions for an account, where we
find another reusable row.
Previews.swift
extension Transaction {
static let preview = Transaction(
amount: 123456,
beneficiary: "Salary",
date: Date()
)
}
Page 37 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
TransactionsView.swift
private struct Row: View {
let transaction: Transaction
var body: some View {
VStack(alignment: .leading, spacing: 4.0) {
HStack {
Text(transaction.beneficiary)
.font(.headline)
Spacer()
Text(transaction.amount.centsFormatted)
.font(.headline)
}
Text(transaction.date.formatted(date: .long,
time: .shortened))
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
#Preview("Row") {
List {
Row(transaction: .preview)
}
}
Page 38 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Keeping views separated from the app business logic
Thanks to the modular views we just created, the code for our app’s screens will
be short and readable. Let’s start with the list of transactions for an account.
Page 39 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
TransactionsView.swift
private struct Content: View {
let account: Account
let addTransactionAction: () -> Void
var body: some View {
List(transactions) { transaction in
Row(transaction: transaction)
}
.safeAreaInset(edge: .bottom) {
AddButton(title: "New Transaction", action:
addTransactionAction)
}
.navigationBarTitle(account.name)
}
var transactions: [Transaction] {
account.transactions
.sorted(using: KeyPathComparator(\.date, order: .reverse))
}
}
#Preview {
NavigationStack {
Content(account: .preview, addTransactionAction: {})
}
}
Page 40 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
What is important to note here is how the Content view respects the two
responsibilities of the view layer while leaving the rest outside.
It lays out the user interface for the entire screen, sets the navigation bar title,
and enables user interaction with the New Transaction button.
Page 41 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
It also sorts the transactions by date so that the most recent ones are at the
top. This is all part of the user interface logic that belongs to the view layer, and
it does not influence the app’s global state in the StateController, which
can sort and store transactions in any order that makes sense.
The same happens for all other views of our app.
NewTransactionView.swift
private struct Content: View {
@Binding var amount: String
@Binding var beneficiary: String
var body: some View {
Form {
LabeledContent {
TextField(0.centsFormatted, text: $amount)
.multilineTextAlignment(.trailing)
.keyboardType(.decimalPad)
} label: {
Text("Amount")
}
TextField("Beneficiary name", text: $beneficiary)
}
.navigationBarTitle("New Transaction")
}
}
#Preview {
@Previewable @State var amount = ""
@Previewable @State var beneficiary = ""
NavigationStack {
Content(amount: $amount, beneficiary: $beneficiary)
}
}
Page 42 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Again, this Content view lays out the entire user interface like the other did.
Moreover, keeping view code separate from the controller layer makes creating
previews more straightforward. Since the Content view does not interact with
objects, we can pass simple bindings as parameters in the preview.
Page 43 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
The view to create new accounts works the same way.
NewAccountView.swift
private struct Content: View {
@Binding var name: String
@Binding var kind: Account.Kind
var body: some View {
Form {
TextField("Account name", text: $name)
Picker("Kind", selection: $kind) {
ForEach(Account.Kind.allCases, id: \.self) { kind in
Text(kind.rawValue).tag(kind)
}
}
}
.navigationBarTitle("New Account")
}
}
#Preview {
@Previewable @State var name = ""
@Previewable @State var kind = Account.Kind.checking
NavigationStack {
Content(name: $name, kind: $kind)
}
}
Page 44 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Again, this view contains interactive views like TextField and Picker but
does not handle any of the app’s business logic.
And finally, we have the list of accounts.
Page 45 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
AccountsView.swift
private struct Content: View {
@Binding var accounts: [Account]
let addAccountAction: () -> Void
var body: some View {
List($accounts, editActions: [.delete, .move]) { $account in
NavigationLink(value: account) {
Row(account: account)
}
}
.safeAreaInset(edge: .bottom) {
AddButton(title: "Add Account", action: addAccountAction)
}
.navigationBarTitle("Accounts")
.toolbar {
ToolbarItem(placement: .primaryAction) {
EditButton()
}
}
}
}
#Preview {
NavigationStack {
Content(accounts: .constant([.preview]), addAccountAction: {})
}
}
Page 46 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
This view allows users to reorder the accounts on the screen through the
accounts binding passed to the List. But again, it does not interact with the
StateController where those accounts are stored.
Page 47 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
This is what we have created until now.
The lower and upper layers of our app are still disconnected. The root layer,
which we will cover in the next chapter of this guide, connects them all.
Page 48 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Bringing the whole app
together in the root layer
We have finally come to the last layer of the MVC pattern: the root layer.
Note
As previously mentioned, this is a concept I developed, and you won’t find it
anywhere else unless this idea catches on.
However, I have been using it since the introduction of SwiftUI in 2019, and I
haven’t seen anyone else use it. If you spot it somewhere else, let me know.
Here we find all the remaining responsibilities for our app:
• controlling the navigation flow;
• connecting views to controllers and
• interpreting user action.
Managing the app’s navigation structure with
architectural views and modal presentation
For starters, we need a single instance of the StateController that can be
accessed by all the screens in our app. We create that in our app’s main App
structure and then pass it to all other views using the environment.
Page 49 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
BankingApp.swift
@main
struct BankingApp: App {
@State private var stateController = StateController()
var body: some Scene {
WindowGroup {
ContentView()
.environment(stateController)
}
}
}
Now, any view can access the shared StateController instance using the
@Environment property wrapper.
Let’s start from the first screen in our app.
AccountsView.swift
struct AccountsView: View {
@Environment(StateController.self) private var stateController
@State private var isAddingAccount = false
var body: some View {
@Bindable var stateController = stateController
Content(accounts: $stateController.accounts) {
isAddingAccount = true
}
.sheet(isPresented: $isAddingAccount) {
NavigationStack {
NewAccountView()
}
}
}
}
Page 50 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
This is our first root view. Its code is entirely structural. It connects the
accounts binding of the Content view to the accounts property of
StateController.
It also modally presents the NewAccountView and provides a
NavigationStack to enable its navigation bar.
The AccountsView also needs a NavigationStack because its Content
view contains a NavigationLink. We place it in the ContentView, also a
root view.
ContentView.swift
struct ContentView: View {
var body: some View {
NavigationStack {
AccountsView()
.navigationDestination(for: Account.self) { account in
TransactionsView(account: account)
}
}
}
}
Here, we also find the navigationDestination(for:destination:)
modifier that controls the destination when selecting an Account value.
This is convenient when multiple navigation links around the app lead to the
same destination. It also keeps the navigation code in a single place, making it
easy to see the entire app’s navigation structure.
The same happens in the TransactionsView.
Page 51 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
TransactionsView.swift
struct TransactionsView: View {
let account: Account
@Environment(StateController.self) private var stateController
@State private var isAddingTransaction: Bool = false
var body: some View {
if let account = stateController[iban: account.iban] {
Content(account: account) {
isAddingTransaction = true
}
.sheet(isPresented: $isAddingTransaction) {
NavigationStack {
NewTransactionView(account: account)
}
}
}
}
}
The TransactionsView structure connects its Content view to the shared
StateController instance and presents the NewTransactionView.
Note
I use the —View suffix only for root views, as it makes it easy to identify them by
their names.
This is a personal convention; you can pick any suffix you prefer.
Page 52 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Mapping user action into the app’s business logic
All we have left are the views to create new accounts and transactions.
NewAccountView.swift
struct NewAccountView: View {
@Environment(StateController.self) private var stateController
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var kind: Account.Kind = .checking
var body: some View {
Content(name: $name, kind: $kind)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel) {
dismiss()
}
}
ToolbarItem(placement: .primaryAction) {
Button("Create") {
stateController.addAccount(named: name,
withKind: kind)
dismiss()
}
.bold()
}
}
}
}
Here, we find the first example of mapping the user action into the app’s
business logic.
The NewAccountView collects the data from its Content view into two
@State properties. Then, when the user taps the Create button, it creates a
new account by calling the addAccount(named:withKind:) method of the
StateController.
Page 53 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Notice that we are directly accessing the StateController instead of
passing data up the view hierarchy using bindings.
The NewAccountView is responsible for creating a new account. If we passed
data up the view hierarchy using bindings, we would have to move the relative
code to the AccountView.
Finally, our NewAccountView also manages navigation, dismissing the
presented screen when the user creates an account or cancels. In a networked
app, it would start the relative network request and dismiss itself only after the
operation succeeded.
Note
You might notice that I added the navigation buttons in the root view here,
while in the AccountsView, I did it in the Content view.
It is debatable where such functionality should go since it resides at the border
between the view and the root layer.
In these cases, I place the code where it makes the most sense. However, I
generally prefer to put it in the root view.
Page 54 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
The same happens in the NewTransactionView, which adds new
transactions to an account through the StateController.
NewTransactionView.swift
struct NewTransactionView: View {
let account: Account
@Environment(StateController.self) private var stateController
@Environment(\.dismiss) private var dismiss
@State private var amount: String = ""
@State private var beneficiary: String = ""
var body: some View {
Content(amount: $amount, beneficiary: $beneficiary)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel) {
dismiss()
}
}
ToolbarItem(placement: .primaryAction) {
Button("Send") {
let amount = (Int(self.amount) ?? 0) * 100
stateController.addTransaction(
withAmount: amount,
beneficiary: beneficiary,
to: account
)
dismiss()
}
.bold()
}
}
}
}
We finally covered the entire app. We provided all its navigation flow with the
root layer and connected all screens to the app’s shared state.
Page 55 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
I had to omit some of the arrows, or the diagram would become too messy. You
can find the missing connections in the other diagrams of this guide.
Thanks to the MVC pattern, we have a well-structured and understandable app.
Each type, be it a model type, a controller, or a view, is small and manageable
and contains only a limited amount of responsibilities.
Page 56 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Moreover, we have a map that allows us to find any specific code quickly.
We can now expand the app, adding new features and screens, knowing
exactly where to place any new code, and keeping the project manageable as it
grows.
Page 57 of 58
SwiftUI App Architecture: Design Patterns and Best Practices
Please Share this Guide
I hope you enjoyed this guide and that it helped you improve your
understanding of SwiftUI app architecture. It distills lessons that took me years
to learn and it took me many hours of work to put it together, but I am happy
to offer it free of charge.
If you found it useful, please share it with someone who might also find it
useful. This way, I can dedicate more time to creating free articles and guides to
help you in your iOS development journey.
Think about colleagues and friends who could find this guide useful and send
them this link through email or private message so that they can get this guide
together with all the other material I only share with my email list:
matteomanferdini.com/swiftui-app-architecture
Thank you.
Matteo
Page 58 of 58
Download