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 GUIDEArchitectural design patterns like VIPER might seem radically different from common ones like MVVM. However, upon deeper inspection, it turns out that these patterns share the same constitutive components. In this article, we will compare the MVVM and VIPER design patterns in SwiftUI and show how they follow the same principles. Table of contents Architectural ... Read more
The post Why VIPER and MVVM in SwiftUI are actually the same pattern: A lesson in architectural thinking appeared first on Matteo Manferdini.
]]>Architectural design patterns like VIPER might seem radically different from common ones like MVVM.
However, upon deeper inspection, it turns out that these patterns share the same constitutive components.
In this article, we will compare the MVVM and VIPER design patterns in SwiftUI and show how they follow the same principles.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEWhen it comes to adopting an architectural pattern for SwiftUI, a common yet unhelpful answer is that “it depends” on your app’s needs.
After many years of research, teaching, and practical implementations, I disagree with that answer.
While implementation details often differ, there are specific architectural components that any software application requires.
Software architecture is similar to classical architecture.
While buildings from different countries and periods differ widely, they still share the same constitutive principles and components, i.e., solid foundations, a roof, doors, windows, and a structure with specific proportions.
The same applies to software design patterns.
For this article, I chose MVVM and VIPER to highlight their similarities. Since they superficially look completely different, they offer a perfect opportunity to exercise your architectural thinking and understand how the underlying principles come from the same original pattern: MVC.
I chose VIPER for this article purely for didactic reasons, not because it is particularly relevant to SwiftUI development nor because I recommend it.
VIPER didn’t gain traction in SwiftUI because, in my opinion, the initial attempts didn’t fully grasp the core architectural principles and thus failed to adapt the pattern to a new framework.
The main reason why developers think MVVM and VIPER are radically different is that, when you compare the diagrams you commonly find online, they look nothing like each other.
This misunderstanding is also exacerbated by other factors.
Nominally, VIPER is an iOS implementation of the Clean Architecture of Robert C. Martin, the same author of the SOLID principles. However, in my research, I never found an explicit explanation of how VIPER follows those architectural principles.
What you usually find are unhelpful links to this blog post, which does not really explain anything. Equally unhelpful is the often-cited circular diagram that you can also find in this article.
This diagram alone isn’t necessarily wrong. It is actually useful to show the dependency rule outlined in the linked article.
The problem is the assumption that it clearly shows VIPER’s structure and principles, which it doesn’t.
What I never see is the actual architectural diagram of a typical scenario from the Clean Architecture book, which would at least better align with VIPER.
However, this diagram still hides the parallels between the Clean Architecture and MVC and MVVM as commonly understood in iOS development.
If anything, it makes the matter even muddier since it introduces several novel terms, such as interactor, presenter, or entities.
Moreover, it makes matters worse by using known terms in ways we are not accustomed to in iOS and SwiftUI development, i.e., the controller and view model components you see in the diagram are not what you would usually identify as such in SwiftUI.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEAs the common adage goes, a picture is worth a thousand words. While the wrong diagram can mislead you, the correct one can immediately reveal how MVVM and VIPER are practically identical.
The trick is recognizing the role each component plays in the app’s overall architecture, rather than merely considering what they are called.
It is also important to note that the correspondence between elements is not strictly one-to-one; it is only in roles.
As we will see, VIPER prescribes a wireframe for each module, which is externally configured by a central dependency object. MVVM, instead, generally uses a single coordinator that incorporates routing and dependency injection.
You should adopt design patterns and architectural principles only when they benefit you.
VIPER is extremely prescriptive, forcing unnecessary abstractions and filling your code with boilerplate. It causes a high degree of indirection that is tedious to implement and hard to follow, with no particular benefit over more flexible approaches.
For these reasons, I do not recommend using VIPER in your SwiftUI apps, even though, as I will show, it follows the same ideas as MVVM.
To show the parallels between VIPER and MVVM, I will recreate the Todo app presented in the original article that popularized VIPER.
Since that app was built in UIKit, the resulting SwiftUI app will be slightly different to adapt to the framework.
Moreover, I will omit some superfluous elements that the original app implemented to strictly adhere to the full Clean Architecture diagram, since they do not add anything to our discussion, and I am not a fan of strict patterns anyway.
You can find the final Xcode project on GitHub.
The most straightforward parallel we can draw is between the view layers of both patterns.
Both in MVVM and VIPER, views are independent of the underlying implementation. These are what I call content views, which only receive simple types as parameters and are immediately previewable in Xcode.
struct AddContentView: View {
@Binding var name: String
@Binding var date: Date
let saveAction: () -> Void
let cancelAction: () -> Void
var body: some View {
Form {
TextField("Name", text: $name)
DatePicker("Date", selection: $date)
.datePickerStyle(.graphical)
}
.navigationTitle("Add todo")
.toolbar {
ToolbarItem {
Button("Save", role: .confirm, action: saveAction)
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel, action: cancelAction)
}
}
}
}
#Preview {
@Previewable @State var name: String = ""
@Previewable @State var date: Date = Date()
NavigationStack {
AddContentView(
name: $name,
date: $date,
saveAction: {},
cancelAction: {})
}
}
The other straightforward parallel is between MVVM’s_ model types_ and VIPER’s_ entities_, which represent the app’s data types.
We will use SwiftData in our app, so our only data type is a @Model class.
@Model
final class TodoItem {
var name: String
var date: Date
init(name: String, date: Date) {
self.name = name
self.date = date
}
}
One of the strict prescriptions of VIPER is to convert data coming from lower layers into a viewable form. That would be the view model component you can see in the original Clean Architecture diagram, which differs from the view model layer of MVVM.
This is an idea I use from time to time, which I call the view data layer instead. A view data type contains the logic to format data for display by the view layer.
However, I generally do not like creating a plethora of tiny types with little to no logic. I would use a view data type only when model types are difficult to instantiate.
Otherwise, a more straightforward, Swifty approach is to use Swift extensions for the formatting logic.
extension TodoItem {
var weekday: String {
let calendar = Calendar.current
return calendar.weekdaySymbols[calendar
.component(.weekday, from: date) - calendar.firstWeekday + 1]
}
}
extension [TodoItem] {
static let today: [TodoItem] = [
TodoItem(name: "Grab coffee", date: Date())
]
static let nextWeek: [TodoItem] = [
TodoItem(
name: "Stock up on water",
date: Calendar.current.date(byAdding: .day, value: 7, to: Date())!
),
TodoItem(
name: "Visit labs",
date: Calendar.current.date(byAdding: .day, value: 8, to: Date())!
),
TodoItem(
name: "Fly home",
date: Calendar.current.date(byAdding: .day, value: 9, to: Date())!
)
]
}
This allows using model types straight away in content views without needing intermediate types and the corresponding conversion code, and without losing the view’s previewability.
struct ListContentView: View {
let todayItems: [TodoItem]
let nextWeekItems: [TodoItem]
let addAction: () -> Void
var body: some View {
List {
Section("Today") {
ForEach(todayItems) { item in
Label(item.name, systemImage: "checkmark")
}
}
Section("Next week") {
ForEach(nextWeekItems) { item in
Label(item.name, systemImage: "calendar")
.badge(Text(item.weekday))
}
}
}
.navigationTitle("Todo")
.toolbar {
Button("", systemImage: "plus", action: addAction)
}
}
}
#Preview {
NavigationStack {
ListContentView(
todayItems: .today,
nextWeekItems: .nextWeek,
addAction: {
})
}
}
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEWhen we concentrate on the app’s business logic, it’s also pretty straightforward to draw parallels between MVVM and VIPER.
VIPER’s data stores are (shared) objects that access an underlying centralized data store, such as a database or a REST API.
These are what you would call controllers in MVC and MVVM, but you might also refer to them as managers, services, or other names.
A data store object provides an agnostic interface to the underlying database, decoupling it from the rest of the app. Since we are using SwiftData, our data store provides methods to fetch and save data in a SwiftData model context.
@Observable class DataStore {
let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
func todoItems(between start: Date, and end: Date) -> [TodoItem] {
let items = try? modelContext.fetch(
FetchDescriptor(
predicate: #Predicate { $0.date > start && $0.date < end },
sortBy: [SortDescriptor(\.date)]
)
)
return items ?? []
}
func save(_ item: TodoItem) {
modelContext.insert(item)
try! modelContext.save()
}
}
Clean architecture and VIPER prescribe transforming the underlying store data into our app’s entities, which should remain separate. You can see how that can be a useful concept when the underlying database returns data in a raw format, such as database rows, or the JSON data returned by a REST API.
However, in our case, the SwiftData model context already uses the same TodoItem class we use for our app’s entities, so there is no need for two separate data representations.
A less obvious parallel to draw is between VIPER’s interactors and MVVM’s view models. However, this is only because of their names, and because Clean Architecture uses the term view model for a different component.
If instead we look at their architectural roles, the connection is pretty evident. Interactors, like view models, implement the app’s business logic for a specific app screen.
So, for example, we can create an interactor that fetches the todo items for today and next week from our SwiftData data store.
@Observable class ListInteractor {
let dataStore: DataStore
private(set) var todayItems: [TodoItem] = []
private(set) var nextWeekItems: [TodoItem] = []
init(dataStore: DataStore) {
self.dataStore = dataStore
}
func findUpcomingItems() {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let tomorrow = calendar.startOfDay(
for: calendar.date(byAdding: .day, value: 1, to: today)!
)
todayItems = dataStore.todoItems(between: today, and: tomorrow)
let nextWeek = calendar.date(byAdding: .day, value: 7, to: today)!
let endOfNextWeek = calendar.nextWeekend(startingAfter: nextWeek)!.end
nextWeekItems = dataStore.todoItems(between: nextWeek, and: endOfNextWeek)
}
}
One way in which VIPER differs from MVVM is that an interactor should not let entities pass through the output boundary; instead, it should return a dedicated output data structure.
This would create another plain Swift type, which is rarely beneficial in a SwiftUI app. Our ListInteractor returns arrays of TodoItem, which we already let emerge at the view level into the ListContentView.
Another difference between VIPER and my recommended MVVM approach is that VIPER prescribes an interactor for every app module, whereas I would implement a view model for an app’s screen only when required.
For example, the interactor for adding a new todo item contains very little code. I would not bother creating such a tiny view model; I would leave the code in the view instead.
@Observable class AddInteractor {
let dataStore: DataStore
var name: String = ""
var date: Date = Date()
init(dataStore: DataStore) {
self.dataStore = dataStore
}
func save() {
let newItem = TodoItem(name: name, date: date)
dataStore.save(newItem)
}
}
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEProbably, the least evident parallel is how to translate VIPER’s presenters into MVVM in SwiftUI.
Looking at the various attempts I found online, there is a recurring effort to force an extra presenter object into the pattern, which does not work well with SwiftUI.
The solution is in realizing that not all SwiftUI views are the same.
Most of an app’s views are content views, including all the standard SwiftUI views provided by the framework. The views at the root of a hierarchy, however, are different.
These are the views that usually access shared objects (controllers/data stores) or state objects (view models/interactors). Thus, VIPER’s presenters are, in SwiftUI, what I call root views.
Root views act as a bridge between content views and lower-level architectural layers, interpreting the user’s input and actions in accordance with the app’s business logic.
struct ListPresenterView: View {
@State var interactor: ListInteractor
var body: some View {
ListContentView(
todayItems: interactor.todayItems,
nextWeekItems: interactor.nextWeekItems,
addAction: { }
)
.onAppear { interactor.findUpcomingItems() }
}
}
Again, the prescriptive nature of VIPER requires a presenter for every app module, which is no different from MVVM, which naturally leads to a root view for almost every app screen.
struct AddPresenterView: View {
@State var interactor: AddInteractor
var body: some View {
NavigationStack {
AddContentView(
name: $interactor.name,
date: $interactor.date,
saveAction: { interactor.save() },
cancelAction: { }
)
}
}
}
For now, each module exists in isolation, without referencing the others. This is intentional, as Clean Architecture and VIPER aim to minimize coupling.
In VIPER, the routing responsibility, i.e., navigation, is shared across the presenter and the wireframe layers. Loosely speaking, this is akin to using coordinators in MVVM.
Here, the correspondence is not one-to-one. VIPER requires each module to have its own wireframe that creates the module’s presenter and connects it to other modules.
@Observable class AddWireframe {
let interactor: AddInteractor
var delegate: AddDelegate?
init(interactor: AddInteractor) {
self.interactor = interactor
}
var interface: some View {
NavigationStack {
AddPresenterView(interactor: interactor, wireframe: self)
}
}
func finish() {
delegate?.addModuleDidFinish()
}
}
protocol AddDelegate {
func addModuleDidFinish()
}
struct AddPresenterView: View {
@State var interactor: AddInteractor
@State var wireframe: AddWireframe
var body: some View {
AddContentView(
name: $interactor.name,
date: $interactor.date,
saveAction: {
interactor.save()
wireframe.finish()
},
cancelAction: { wireframe.finish() }
)
}
}
As wireframes control the app’s structure and navigation, they inject the dependencies into the presenters, including themselves.
Since the add module is presented by some other module, the AddWireframe needs to use delegation to notify the presenting wireframe to navigate back. In MVVM, this would be simpler, as the AddPresenterView would communicate directly with the navigation coordinator.
The list module gets its own wireframe, which defines its interface and its connection to the add module via AddWireframe.
@Observable class ListWireframe {
let listInteractor: ListInteractor
let addWireframe: AddWireframe
private(set) var isAddInterfacePresented = false
init(listInteractor: ListInteractor, addWireframe: AddWireframe) {
self.listInteractor = listInteractor
self.addWireframe = addWireframe
}
var interface: some View {
@Bindable var wireframe = self
return NavigationStack {
ListPresenterView(interactor: listInteractor, wireframe: self)
.fullScreenCover(isPresented: $wireframe.isAddInterfacePresented) {
self.addWireframe.interface
}
}
}
func presentAddInterface() {
isAddInterfacePresented = true
}
}
extension ListWireframe: AddDelegate {
func addModuleDidFinish() {
isAddInterfacePresented = false
}
}
struct ListPresenterView: View {
@State var interactor: ListInteractor
@State var wireframe: ListWireframe
var body: some View {
ListContentView(
todayItems: interactor.todayItems,
nextWeekItems: interactor.nextWeekItems,
addAction: { wireframe.presentAddInterface() }
)
.onAppear { interactor.findUpcomingItems() }
.onChange(of: wireframe.isAddInterfacePresented) {
interactor.findUpcomingItems()
}
}
}
While VIPER’s wireframes guarantee complete isolation between modules, this comes at the cost of a far more complex architecture and additional boilerplate code to connect modules through their wireframes.
It also ignores the affordances offered by SwiftUI, such as environment objects and SwiftData’s @Query macro, because responsibilities must be strictly assigned to each component.
This is by design, and it is what makes VIPER unnecessarily convoluted and hard to follow unless you are already familiar with its structure.
This happens with any other SwiftUI pattern that follows the strict separation dictated by the Clean Architecture, like Clean Swift, which its author seems to have abandoned.
This is also evident in The Composable Architecture, as testified by this experience. The whole pattern relies on a proprietary framework that abandons SwiftUI’s core features in favor of a plethora of custom macros.
Like VIPER’s wireframes, TCA moves the entire app structure away from SwiftUI views into connected reducers. This is exacerbated by an extensive use of Swift enumerations to represent state and its transitions, which I consider an antipattern.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEThere is one last piece to the puzzle. VIPER’s wireframes only define the connections between modules. However, wireframes are not responsible for instantiation or dependency injection.
That is usually done through a central object that is never shown in any architectural diagram. In VIPER, this is called the dependencies object, and it is again similar to an MVVM coordinator.
@Observable class Dependencies {
let modelContainer: ModelContainer
let dataStore: DataStore
let rootWireframe: ListWireframe
init() {
let schema = Schema([TodoItem.self])
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
let container = try! ModelContainer(
for: schema,
configurations: [configuration]
)
modelContainer = container
dataStore = DataStore(modelContext: container.mainContext)
rootWireframe = Self.setDependencies(dataStore: dataStore)
}
var rootInterface: some View {
rootWireframe.interface
}
static func setDependencies(dataStore: DataStore) -> ListWireframe {
let listInteractor = ListInteractor(dataStore: dataStore)
let addInteractor = AddInteractor(dataStore: dataStore)
let addWireframe = AddWireframe(interactor: addInteractor)
let listWireframe = ListWireframe(
listInteractor: listInteractor,
addWireframe: addWireframe
)
addWireframe.delegate = listWireframe
return listWireframe
}
}
@main
struct TodoApp: App {
@State private var dependencies = Dependencies()
var body: some Scene {
WindowGroup {
dependencies.rootInterface
}
}
}
However, there is also a fundamental difference. An MVVM coordinator creates and injects dependencies only as needed. The Dependencies class above, instead, creates the entire dependency graph at launch.
This can cause several problems.
First of all, it unnecessarily increases your app’s memory footprint.
Moreover, the dependency object graph retains and reuses wireframe and interactor objects. Using common @State properties instead creates new instances as the user navigates through the app.
If you run the Todo app, you will see this problem when the Add Todo app screen is presented a second time. After creating one todo item, adding a second one will display a user interface that shows old data rather than an empty one.
I intentionally didn’t fix that to show you the problems arising from an unnecessarily complicated architecture that goes against how SwiftUI works.
This can obviously be fixed in multiple ways, but it adds extra cognitive load on the developer, who needs to fix or prevent issues that wouldn’t occur in vanilla SwiftUI code.
While MVVM and VIPER may look radically different at first glance, it is possible to draw parallels between their components based on their roles.
Views and entities work the same in both patterns, data stores are the same as controllers, and interactors are equivalent to view models.
The parallel between other layers might seem less straightforward, but presenters can be compared to the root views that naturally emerge in SwiftUI apps, and wireframes cover the same role as coordinators.
Despite all these parallels, VIPER remains more prescriptive and convoluted than MVVM. Its architecture is harder to follow, leading to a lot of superfluous boilerplate code.
Finally, its complexity can cause structural issues and imposes a higher cognitive load on the developer.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEThe post Why VIPER and MVVM in SwiftUI are actually the same pattern: A lesson in architectural thinking appeared first on Matteo Manferdini.
]]>SwiftUI provides several tools for managing navigation, and the introduction of NavigationStack and value-destination links improved programmatic navigation. However, in larger applications, vanilla SwiftUI navigation can pose challenges for testability, maintainability, and modularity. Navigation logic is distributed across views, introducing coupling and making the navigation code hard to locate. These problems can be addressed by ... Read more
The post From broken to testable SwiftUI navigation: The decoupled approach of MVVM with coordinators appeared first on Matteo Manferdini.
]]>SwiftUI provides several tools for managing navigation, and the introduction of NavigationStack and value-destination links improved programmatic navigation.
However, in larger applications, vanilla SwiftUI navigation can pose challenges for testability, maintainability, and modularity. Navigation logic is distributed across views, introducing coupling and making the navigation code hard to locate.
These problems can be addressed by integrating coordinators into the MVVM pattern.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDESwiftUI’s NavigationStack allows you to build sophisticated navigation hierarchies. For example, this is a typical arrangement for an app featuring a tab view, with individual drill-down navigation in each tab.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
}
}
The NavigationLink view allows us to specify:
While these are both useful, depending on the use case, they both cause architectural issues at scale.
You can follow the code in this article by downloading the complete Xcode project from GitHub.
I will deliberately keep the examples simple to make them straightforward to understand.
It is crucial to understand that none of the following examples, by itself, constitutes a problem. All examples will show perfectly acceptable code for simple apps.
Problems arise only when a codebase grows and its architectural requirements change.
Value-destination links operate on types. For example, we can display a RecipeView when a Recipe value is pushed onto the navigation path.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
RecipeView(recipe: recipe)
}
}
}
// ...
}
}
}
This approach remains purely declarative until we need to inspect data to make a navigation decision. For example, our recipes app might offer premium recipes gated behind a paywall.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
if recipe.isPremium {
PaywallView()
} else {
RecipeView(recipe: recipe)
}
}
}
}
// ...
}
}
}
Incorporating these checks introduces non-UI logic into views, creating several architectural problems:
We can solve the first two problems straight away by moving the navigation logic into a coordinator class.
@Observable final class Coordinator {
@ViewBuilder func destination(for recipe: Recipe) -> some View {
if recipe.isPremium {
PaywallView()
} else {
RecipeView(recipe: recipe)
}
}
}
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
coordinator.destination(for: recipe)
}
}
}
// ...
}
}
}
However, testing this code is not straightforward, since the @ViewBuilder attribute causes the destination(for:) to return a _ConditionalContent<PaywallView, RecipeView> value that we cannot inspect.
We will see how to fix this by the end of the article.
Coordinators were originally introduced in iOS development by Soroush Khanlou, who took the idea from Martin Fowler, the creator of MVVM.
A similar idea can be found in VIPER’s wireframes, a pattern derived from the Clean Architecture of Robert C. Martin, who introduced the SOLID principles.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEAnother problem arises with views that use a view model that requires dependencies to be injected via its initializer.
Because environment objects are not yet available in a view’s initializer, the view model must be initiated in the task(priority:_:) view modifier and stored in an optional property.
@Observable final class NetworkController {
// ...
}
@Observable final class ViewModel {
let networkController: NetworkController
init(networkController: NetworkController) {
self.networkController = networkController
}
}
struct PaywallView: View {
@State private var viewModel: ViewModel?
@Environment(NetworkController.self) private var networkController
var body: some View {
Text("Hello, World!")
.navigationTitle("Paywall")
.task {
guard viewModel == nil else { return }
viewModel = ViewModel(networkController: networkController)
}
}
}
Many developers take issue with this approach, complaining that optionals lead to several annoying unwrapping steps in the view’s code.
However, instantiating the view model in the parent of PaywallView would introduce additional coupling across views and violate the Single Responsibility and the Don’t Repeat Yourself (DRY) principles, especially when the PaywallView is accessible via multiple navigation paths.
We can avoid these problems and remove the optional from the view by injecting the dependencies into the view model from a coordinator.
struct PaywallView: View {
@State private var viewModel: ViewModel
init(viewModel: ViewModel) {
self._viewModel = State(initialValue: viewModel)
}
var body: some View {
Text("Hello, World!")
.navigationTitle("Paywall")
}
}
@Observable final class Coordinator {
let networkController = NetworkController()
@ViewBuilder func destination(for recipe: Recipe) -> some View {
if recipe.isPremium {
let viewModel = ViewModel(networkController: networkController)
PaywallView(viewModel: viewModel)
} else {
RecipeView(recipe: recipe)
}
}
}
The NetworkController might also need to be injected into the Coordinator through an initializer.
At times, there might not be a relationship between data and navigation. In such cases, SwiftUI offers view-destination links rather than value-destination links.
For example, a Settings view might explicitly declare the destination view for each row in a Form.
struct SettingsView: View {
var body: some View {
Form {
NavigationLink(destination: { ProfileView() }) {
Label("Profile", systemImage: "person.crop.circle")
}
NavigationLink(destination: { AllergiesView() }) {
Label("Allergies", systemImage: "leaf")
}
}
.navigationTitle("Settings")
}
}
You would be able to gather the mapping of value-destination links inside several navigationDestination(for:_:) view modifiers at the root of a navigation tree inside a NavigationStack.
However, view-destination links distribute navigation responsibilities across multiple views, as they must reside in the view triggering the navigation.
This introduces coupling between views, and, in large apps, it makes it challenging to locate the exact navigation points in the codebase. Identifying specific routes can be time-consuming, and code updates can affect unrelated parts of the system.
Coordinators let us gather all navigation destinations in a single location, making it easier to understand the app’s entire navigation at a glance without drilling into the codebase.
@Observable final class Coordinator {
// ...
@ViewBuilder func profileSettings() -> some View {
ProfileView()
}
@ViewBuilder func allergiesSettings() -> some View {
ProfileView()
}
}
The coordinator also removes coupling between views.
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView(selection: $coordinator.tab) {
// ...
}
.environment(coordinator)
}
}
struct SettingsView: View {
@Environment(Coordinator.self) private var coordinator
var body: some View {
Form {
NavigationLink(destination: { coordinator.profileSettings() }) {
Label("Profile", systemImage: "person.crop.circle")
}
NavigationLink(destination: { coordinator.allergiesSettings() }) {
Label("Allergies", systemImage: "leaf")
}
}
.navigationTitle("Settings")
}
}
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEAnother problem caused by value-destination and view-destination links at scale is that they decentralize navigation management, making it hard or impossible to bring an app to a specific state via a deep link.
A coordinator can instead control the entire navigation state of an app, including tab views and modal presentation.
First, it should be noted that view-destination links are not suitable for deep linking. According to Apple’s documentation:
A view-destination link is fire-and-forget: SwiftUI tracks the navigation state, but from your app’s perspective, there are no stateful hooks indicating you pushed a view.
Hence, we need values even for those paths that we would typically handle with view-destination links.
enum AppSection {
case recipes, settings
}
enum SettingsRoute {
case main, profile, allergies
}
@Observable final class Coordinator {
var appSection: AppSection = .recipes
var settingsPath: [SettingsRoute] = []
//...
@ViewBuilder func view(for route: SettingsRoute) -> some View {
switch route {
case .main: SettingsView()
case .profile: ProfileView()
case .allergies: AllergiesView()
}
}
func handleURL(_ url: URL) {
appSection = .settings
settingsPath = [.main, .allergies]
}
}
The view(for:) method maps each SettingsRoute to a view. The handleURL(_:) method can then respond to a deep link, switching the app to the Settings tab and pushing the AllergiesView onto its navigation stack.
The connection is made inside the ContentView, which is the root view where the entire app’s navigation structure is defined.
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView(selection: $coordinator.appSection) {
Tab("Recipes", systemImage: "list.bullet.clipboard", value: .recipes) {
// ...
}
Tab("Settings", systemImage: "gear", value: .settings) {
NavigationStack(path: $coordinator.settingsPath) {
coordinator.view(for: .main)
.navigationDestination(for: SettingsRoute.self) { route in
coordinator.view(for: route)
}
}
}
}
.environment(coordinator)
.onOpenURL { url in
coordinator.handleURL(url)
}
}
}
Deep links often come from outside an app, but they can also be used internally to jump to a specific point in response to user actions or events.
struct RecipesList: View {
@State var recipes = Recipe.data
var body: some View {
List {
ForEach(recipes) { recipe in
// ...
}
Link(
"Set your allergies",
destination: URL(string: "recipes://settings/allergies")!
)
}
.listStyle(.plain)
.navigationTitle("Recipes")
}
}
Do not forget to set your app’s URL scheme in the target’s Info to enable it to respond to incoming deep links.
Thanks to our coordinator, we can now write a test to verify that a deep link leads to the correct location.
@Test func allergiesDeepLink() async throws {
let coordinator = Coordinator()
coordinator.handleURL(URL(string: "recipes://settings/allergies")!)
#expect(coordinator.appSection == .settings)
#expect(coordinator.settingsPath == [.main, .allergies])
}
There is still an indirect link between the SettingsRoute values and the relative views that our test cannot cover. However, that is the domain of UI tests, as the goal of a unit test is to verify an app’s logic.
This means that testing the destination for premium recipes also requires explicitly handling the navigation path of the Recipes navigation stack in the coordinator.
Standard SwiftUI navigation is enough for basic applications, but it introduces several architectural problems at scale.
Coordinators within the MVVM pattern centralize routing, removing view coupling, enabling deep linking, and improving separation of concerns and testability.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEThe post From broken to testable SwiftUI navigation: The decoupled approach of MVVM with coordinators appeared first on Matteo Manferdini.
]]>Some developers claim that MVVM is incompatible with SwiftUI. However, with a proper understanding of SwiftUI, it is possible to address any criticisms and eliminate the boilerplate code seen in many online blogs. In this article, we will explore some fundamental yet ignored SwiftUI features to understand how to replicate its integration with SwiftData inside ... Read more
The post Is SwiftData incompatible with MVVM? The standard answer disregards some key principles driving SwiftUI’s architecture appeared first on Matteo Manferdini.
]]>Some developers claim that MVVM is incompatible with SwiftUI.
However, with a proper understanding of SwiftUI, it is possible to address any criticisms and eliminate the boilerplate code seen in many online blogs.
In this article, we will explore some fundamental yet ignored SwiftUI features to understand how to replicate its integration with SwiftData inside our view models.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEMVVM is not a mandatory design pattern in SwiftUI. However, it has several benefits, as I detailed in my article on view models in SwiftUI, like:
While SwiftUI does not mandate any specific design pattern, MVC could be considered an exception, as it has been at the core of all modern software and Apple’s frameworks since its inception.
Some developers seem to take issue with MVVM in SwiftUI apps, especially when using SwiftData for storage.
Looking at the top Google results, you can find some articles, including ones coming from experienced developers, decrying the shortcomings of pairing SwiftData with MVVM.
Unfortunately, these often provide suboptimal examples; therefore, I’ll spend the rest of this article addressing those complaints and presenting a better approach to the pattern.
As a developer, it is your duty to think critically and never take anything you read online as the absolute truth, even when it comes from developers allegedly far more experienced than you, and even if it seems to be the consensus.
Any argument must be assessed on its own merits and critically challenged. This rule also applies to anything I write, including the article you are reading. In fact, this article underwent several corrections after developers pointed out flaws in my code.
Moreover, Google is never the arbiter of what is true, and you must take anything you find in the top results with a grain of salt, even when it is repeated by many, especially in this age of thoughtless AI-slop.
Most complaints about MVVM with SwiftData stem from the mistaken belief that SwiftUI and SwiftData are inextricably intertwined.
You can find many examples of such a belief online, like the most-voted comment on this Reddit post.
Leaving aside the other claims made by the post for now, the claim that the two frameworks are “designed to be coupled” has no basis in reality. SwiftData is an independent framework that can be used with SwiftUI, UIKit, and AppKit.
In fact, the connections between SwiftData and SwiftUI are pretty thin, even though they are certainly convenient. They boil down to:
modelContainer(_:) and modelContext(_:) instance methods on the Scene and View protocols.That’s it. While these connections make it easier to use SwiftData in SwiftUI, none are actually necessary for its functionality.
I will use, as a starting example, the template code provided by Xcode when creating a new project with SwiftData for storage.
You can find the complete project on GitHub.
All the relevant SwiftData code is in the ContentView.swift file.
ContentView.swift
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
// ...
}
.onDelete(perform: deleteItems)
}
.toolbar {
// ...
ToolbarItem {
Button(action: addItem) {
// ...
}
}
}
} detail: {
// ...
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
Creating a view model for this view requires moving the code implementing the app’s business logic related to the data access layer into a separate class. We can start with the addItem() method.
ViewModel.swift
@Observable class ViewModel {
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
func addItem() {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
This has the immediate benefit of making our code encapsulated) and testable, which was not possible when it was embedded in the view.
SwiftDataMVVMTests.swift
@Test func viewModelInsert() async throws {
let container = ModelContainer.modelContainer(for: Item.self, inMemory: true)
let context = container.mainContext
let viewModel = ViewModel(modelContext: context)
viewModel.addItem()
let items: [Item] = try context.fetch(.init())
#expect(items.count == 1)
}
The ModelContainer class cannot be mocked through subclassing because it’s not declared as open by the SwiftData framework. Attempting to do so will result in a compiler error.
// error: Cannot inherit from non-open class 'ModelContext' outside
// of its defining module
class Mock: ModelContext {
}
Most unit tests for SwiftData code can be performed using an in-memory model context, as shown above.
If you need a mock object, you must use a protocol listing, as requirements, the portion of the ModelContext class interface used by the view model.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEThe code I just showed above is fairly straightforward, and I don’t think anyone would have any issues with it so far. However, the complaints arise as soon as the view model requires an array of items, such as the one in the @Query property of the ContentView.
In our example, that happens when we try to move into the view model the deleteItems(offsets:) method, which references the items property of the ContentView.
Many developers erroneously believe they need to turn the items property into a stored property and complain that such a property must then be updated every time the model context changes, since the @Query macro can be used only inside SwiftUI views.
ViewModel.swift
@Observable class ViewModel {
var items: [Item] = [] // This should not be a stored property
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
update() // This is unnecessary
}
func addItem() {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
update() // This is unnecessary
}
func deleteItems(offsets: IndexSet) {
for index in offsets {
modelContext.delete(items[index])
}
update() // This is unnecessary
}
// This is unnecessary
private func update() {
items = (try? modelContext.fetch(FetchDescriptor())) ?? []
}
}
Critics of MVVM then dismiss the pattern because of their own poor implementation, blaming it for introducing all the above boilerplate code just to keep a single stored property up to date.
If that were the correct way of implementing MVVM, I would agree. The code above is redundant and error-prone, as it’s easy to forget to call the update() method at the appropriate times.
It is also incorrect because it does not respond to changes in the model context caused by code outside the view model, which can lead to subtle and hard-to-find bugs.
The mistake in the above implementation is believing that a @Query property is a single source of truth, as if it were a @State property, that must be moved into the view model.
However, expanding the @Query macro in Xcode reveals that the attached stored property gets transformed into a read-only computed property.
We will focus on the generated private stored property later.
What is important here is that the contents of the items property cannot be modified, even though you can update each Item object independently, because the class has an Observable conformance added by the Model() macro.
A @Query property does not establish a new single source of truth in a SwiftUI view. Instead, it provides read-only access to the underlying model context, which is the real single source of truth for all data stored by SwiftData.
As such, @Query properties do not need to be moved inside a view model. Instead, the view model can access the model context independently, while the original @Query property can keep driving the user interface updates in the SwiftUI view.
This means that, if our ViewModel class needs to access the array of items, it can do so by implementing its own computed property that accesses the underlying model context, rather than a stored property that requires constant updates.
ViewModel.swift
@Observable class ViewModel {
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
private var items: [Item] {
(try? modelContext.fetch(FetchDescriptor())) ?? []
}
func addItem() {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
func deleteItems(offsets: IndexSet) {
for index in offsets {
modelContext.delete(items[index])
}
}
}
Since the ViewModel class requires the model context to be injected through its initializer, it cannot be instantiated as a default property value in the property’s declaration.
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
// error: Cannot use instance member 'modelContext' within property initializer; property initializers run before 'self' is available
private var viewModel: ViewModel = ViewModel(modelContext: modelContext)
// ...
}
It is not strictly necessary to require the model context to be injected through the initializer.
It could also be injected later through a method or an optional stored property. In fact, that is required if we want to avoid having an optional view model in the view.
However, it is a good practice for a class to require its dependencies at initialization, as it removes unnecessary optionals and prevents programming mistakes.
The view model cannot be instantiated in the view’s initializer either, for two reasons:
body runs.@State property in a view’s initializer resets it every time SwiftUI updates the view hierarchy.struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@State private var viewModel: ViewModel
init(modelContext: ModelContext) {
// Recreates the view model every time the view hierarchy is refreshed.
self._viewModel = State(initialValue: ViewModel(modelContext: modelContext))
}
// ...
}
Moreover, passing the model context as a parameter to the initializer
The same applies to creating the view model in the parent view and passing it as an initializer parameter.
The above pattern would work with the old @StateObject property wrapper since, unlike @State, it has an initializer with an autoclosure that runs only once, even if the view’s initializer runs multiple times.
In any case, that works only with objects that do not require an environment value to be injected.
Observable objects in @State properties that require environment values can be properly instantiated in the task(_:) view modifier, as detailed by Apple’s documentation. However, there is a better way, as we will see later in this article.
ContentView.swift
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@State private var viewModel: ViewModel?
var body: some View {
NavigationSplitView {
// ...
} detail: {
// ...
}
.task {
guard viewModel == nil else { return }
viewModel = ViewModel(modelContext: modelContext)
}
}
private func addItem() {
withAnimation {
viewModel?.addItem()
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
viewModel?.deleteItems(offsets: offsets)
}
}
}
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEThe detractors of MVVM in SwiftUI decry the cumbersome initialization pattern I showed above, as well as the annoying optional viewModel property that must be unwrapped at every use.
Some have asked about what I would use in production code. I would stop at the code I’ve shown so far, as I don’t mind initializing the view model in the task(_:) modifier.
I also consider the related optional stored property to be only a minor and acceptable annoyance. However, I know there are developers hell-bent on avoiding it at all costs.
I do have a solution that removes the need for an optional based on the state pattern, but that’s out of scope for this article.
However, I use that only for complex view models, as it is quite verbose and requires a lot more boilerplate code, making it a poor choice if your objective is to just avoid optionals.
Below, I’ll show an alternative.
@Query properties seem to have a mysterious ability to access the model context shared through the SwiftUI environment, which is one of the reasons why many believe SwiftData and SwiftUI are inextricably connected.
However, a bit of curiosity can dissolve that mystery and provide a more convenient way to initialize our view models.
Examining the expansion of the @Query macro, you will notice that the generated stored property has a Query type, a structure that conforms to the DynamicProperty protocol, providing the second piece of the puzzle.
Many developers erroneously believe that a @Query property accesses the shared SwiftData model context using some private API available only to Apple.
However, any property wrapper conforming to the DynamicProperty protocol, including custom ones, can access the SwiftUI environment.
This means we can implement a custom property wrapper that instantiates a view model and accesses the shared model context only when it is available, i.e., when the body of the view is executed.
However, in this case, we can’t inject the model context into the view model at initialization because it isn’t available yet, as discussed above.
SwiftDataViewModel.swift
protocol ContextReferencing {
init()
func update(with modelContext: ModelContext)
}
@propertyWrapper struct SwiftDataViewModel: <VM: ContextReferencing> DynamicProperty {
@State var viewModel = VM()
@Environment(\.modelContext) private var modelContext
var wrappedValue: VM {
return viewModel
}
func update() {
viewModel.update(with: modelContext)
}
}
An earlier version of this code initialized the view model in the update() method if one wasn’t present yet. However, that was a mistake, as it created a new view model instance every time the method was executed.
The ViewModel class needs to conform to the ContextReferencing protocol and receive the model context through method injection.
ViewModel.swift
@Observable final class ViewModel: ContextReferencing {
private var modelContext: ModelContext?
func update(with modelContext: ModelContext) {
self.modelContext = modelContext
}
func addItem() {
let newItem = Item(timestamp: Date())
modelContext?.insert(newItem)
}
func deleteItems(offsets: IndexSet) {
for index in offsets {
modelContext?.delete(items[index])
}
}
}
While this introduces an optional into the view model, it allows updating the model context reference when the one in the SwiftUI environment changes.
We can now use our custom property wrapper in the view and seamlessly initialize the view model, removing the need for the task(_:) view modifier and optional unwrapping.
ContentView.swift
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@SwiftDataViewModel private var viewModel: ViewModel
var body: some View {
NavigationSplitView {
// ...
} detail: {
// ...
}
}
private func addItem() {
withAnimation {
viewModel .addItem()
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
viewModel .deleteItems(offsets: offsets)
}
}
}
With the addition of the SwiftDataViewModel property wrapper, the view model can now incorporate stored properties.
However, keep in mind that this is not particularly important for MVVM with SwiftData, as a view can use a @Query property in conjunction with a view model.
The answer to the riddle is again in the DynamicProperty protocol, to which every SwiftUI property wrapper conforms. In the documentation for its update() requirement, we read:
SwiftUI calls this function before rendering a view’s body to ensure the view has the most recent value.
An earlier version of this section stated that @Query properties are not notified about changes to the model context and do not drive a view refresh.
However, that statement was at least partially incorrect, if not completely, as a reader pointed out in this LinkedIn comment.
@Query properties do trigger view updates when the model context changes, even though we can’t know how it works exactly. I attempted to investigate the exact mechanism, but I was unable to determine how it occurs.
My suspicion is that the Query structure relies on Core Data notifications for updates, since SwiftData is built on top of it, but it could also utilize private APIs.
In the end, the answer remains hidden in Apple’s implementation, at least for the time being. Fortunately, this technical detail does not invalidate the point of this article, which is architectural and not tied to such implementation details.
Thanks to the update() requirement of DynamicProperty, our view model can keep any stored property up to date in a single place, rather than across all its methods, as you commonly see in online examples.
ViewModel.swift
@Observable final class ViewModel: ContextReferencing {
var items: [Item] = []
private var modelContext: ModelContext?
func update(with modelContext: ModelContext) {
self.modelContext = modelContext
items = (try? modelContext.fetch(FetchDescriptor())) ?? []
}
// ...
}
Keep in mind that this is not necessary in our example, as I have explained above, and I would not recommend implementing it without a reason. Using a @Query property in the view is simpler. However, this approach can be useful when a view model is more complex than the one in our example.
The ContentView still needs its @Query property since that is what causes a view refresh, which in turn runs the update() method of our property wrapper.
However, the view can reference the stored property of the view model, which is useful when they are not a mere replica of a @Query property in the view.
ContentView.swift
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@SwiftDataViewModel private var viewModel: ViewModel
var body: some View {
NavigationSplitView {
List {
ForEach(viewModel.items) { item in
// ...
}
.onDelete(perform: deleteItems)
}
// ...
} detail: {
// ...
}
}
// ...
}
Most online claims about MVVM and SwiftData can be debunked, demonstrating that MVVM is perfectly compatible with SwiftData.
With a proper understanding of the single source of truth principle that drives SwiftUI’s architecture, it is possible to combine view models, @Query properties, and the shared model context.
Moreover, the DynamicProperty protocol allows us to remove all boilerplate code and simplify view model initialization.
Finally, the DynamicProperty protocol helps us keep view models up to date with changes in the shared model context, eliminating the need to explicitly update a view model’s stored properties.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEThe post Is SwiftData incompatible with MVVM? The standard answer disregards some key principles driving SwiftUI’s architecture appeared first on Matteo Manferdini.
]]>If you’ve been working with SwiftUI, you’ve likely noticed that your views start pretty simple but then balloon into large, unmaintainable monoliths that are hard to preview and test. While there are several techniques to keep SwiftUI views modular and reusable, some problems are architectural in nature and can only be addressed by following proven ... Read more
The post Why Dismissing View Models in SwiftUI is Stifling your App’s Maintainability and Testability (And the Proven Principles for a Better Architecture) appeared first on Matteo Manferdini.
]]>If you’ve been working with SwiftUI, you’ve likely noticed that your views start pretty simple but then balloon into large, unmaintainable monoliths that are hard to preview and test.
While there are several techniques to keep SwiftUI views modular and reusable, some problems are architectural in nature and can only be addressed by following proven software design principles.
Particularly, view models are an essential component to guarantee testability, maintainability, and code reuse across views.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEIn this chapter:
The core issue of monolithic types is that existing code invites the accumulation of related code, which leads to lower cohesion.
In SwiftUI, that happens when views start accumulating app business logic, often in the form of:
These, in turn, make the code of SwiftUI views tightly coupled, making them hard to maintain, difficult to preview, and impossible to unit test.
The solution to these problems is to adopt an architectural design pattern, such as MVVM, that distributes responsibilities across well-defined layers.
MVVM has been used by numerous developers for over two decades on various platforms, including iOS. However, you often find online arguments against using MVVM in SwiftUI, such as:
This leaves many developers wondering whether they should use view models in their apps or abandon them completely for a new, trendy pattern like the MV pattern.
I have already explained in detail how to use MVVM in SwiftUI, and you can delve deeper into the topic using my free guide on app architecture. I’ll use this article to address those criticisms.
My arguments rest on several software design principles, including:
My arguments require believing that these are timeless principles that still apply, regardless of the programming language or UI framework used, as well as understanding the problems these principles aim to solve.
This is definitely not an opinion shared by everyone, as exemplified by this comment I got on LinkedIn:
You might also have stumbled upon this long rant against MVVM on Apple’s developer forums, where the author calls the SOLID principles and unit testing “a cancer on iOS development”.
If you share those opinions, you probably won’t get much value out of this article, so I’ll hopefully save you time by putting this note up front.
In this chapter:
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDETo avoid being accused of criticizing something nobody said, we will start with an example I took from an online article opposing view models.
This is typically the code for a view that fetches its data from a REST API, a pattern I have seen in many code samples.
struct ItemsView: View {
@State private var viewState: ViewState = .loading
enum ViewState {
case loading
case loaded([Item])
case error(String)
}
var body: some View {
NavigationStack {
List {
switch viewState {
case .loading: // Display the loading view
case .loaded(let array): // Display the list of items
case .error(let string): // Display the error view
}
}
.task { await load() }
}
}
}
We don’t know yet how the view will transition from .loading to .loaded(_:) or .error(_:). However, we can already be certain that some code will express that logic.
Thus, the view’s declarative code will be driven by imperative logic that changes its state.
Tangentially, the ViewState enumeration in the code above is a bad way to represent the states of a view.
Some developers seem to believe it is an appropriate implementation of the state pattern, but it clearly isn’t, as the state pattern requires subclassing.
Generally, enumerations are a poor abstraction mechanism to represent the entire state of an entity.
Enumerations obviously have their application in representing mutually excluding options in individual stored properties. However, since they are sum types, they can’t represent overlapping states as efficiently as product types.
As such, enumerations should not encapsulate the entire state of a view. For example, the code above cannot handle a case where the view still needs to display the previous list of items when a reloading error occurs.
Moreover, using enumerations to abstract the internal state of an entity and its transitions violates the Open-Closed principle.
This becomes evident in the example above when you try to implement the overlap I just mentioned, for example, by adding an Item array to the error case.
To be fair, in such a simple piece of code, you can get away with pretty much anything. Any violation of a software development principle is unlikely to be a significant issue when your views are small enough.
However, those principles exist to prevent problems as code becomes increasingly complex, and are not invalidated by such simple examples.
Therefore, while the examples in this article will be deliberately simple to present my arguments in the most straightforward way possible, I trust your experience with building real apps to help you imagine how each problem would worsen in more complex scenarios.
The loading of the view’s content generally happens in one or more private methods. This is where we find the imperative code that updates the view’s state.
private extension ItemsView {
func load() async {
do {
let (data, _) = try await URLSession.shared
.data(from: URL(string: "example.com")!)
let items = try JSONDecoder().decode([Item].self, from: data)
viewState = .loaded(items)
} catch {
viewState = .error("Error message")
}
}
}
Unfortunately, this function is untestable because of how SwiftUI works and how the @State property wrapper is implemented.
Even though the ItemsView is a Swift structure, creating an individual view value in a unit test and then inspecting the content changes of the viewState property does not work.
Detractors of MVVM think that this does not matter and recommend moving the networking code into a shared object for testability.
@Observable class NetworkController {
func fetchItems() async throws -> [Item] {
let (data, _) = try await URLSession.shared
.data(from: URL(string: "example.com")!)
return try JSONDecoder().decode([Item].self, from: data)
}
}
struct ItemsView: View {
@State private var viewState: ViewState = .loading
@Environment(NetworkController.self) private var networkController
// ...
}
private extension ItemsView {
func load() async {
do {
let items = try networkController.fetchItems()
viewState = .loaded(items)
} catch {
viewState = .error("Error message")
}
}
}
Such a shared object is called a controller in MVC, but is also sometimes referred to as a service, a manager, a store, or a client.
However, the name doesn’t change the nature of the object, which is to be shared across multiple SwiftUI views, either as a singleton or through the SwiftUI environment.
The fetchItems() function is now testable. However, architecturally, this hardly solves anything.
Pushing code like this into a shared object typically violates the DRY (Don’t Repeat Yourself) principle, as the different networking methods end up having code that is structurally similar.
The common solutions to such repetition you find online usually violate:
I illustrate all these problems and the proper way to address them in my article on making REST API Calls in Swift.
Moving networking logic into a shared object does not improve the testability of the imperative code that drives the state changes of a view. In our example, the state transition code of the ItemsView remains within its load() method.
Conditional flow statements like if, switch, and do are an expression of the app’s business logic. However, in this case, such logic does not belong to shared objects, since it is particular to this view. Namely, the view’s logic defines:
.loading to .loaded(_:) or error(_:).error(_:) to .loaded(_:).There is nothing intrinsically wrong with having this code inside a view. However, as we have seen, you can’t unit test that logic, which is particularly important, especially in views with logic more complex than such a toy example.
Moreover, such a view is also difficult or impossible to preview in Xcode, as it usually requires an active internet connection and the instantiation of a database or the entire app networking stack.
Cool. If your argument is that you don’t care about testing the code of your views, then you might not need a view model.
Even Wikipedia acknowledges that (emphasis mine):
A software design pattern is a general, reusable solution to a commonly occurring problem.
If you don’t have a problem, you don’t need its solution. It is a mistake to blindly adhere to any specific design pattern when it’s not required.
However, “I don’t care” is not an argument against MVVM. It’s not even an argument against others caring.
I don’t write view models for sports, and I have plenty of views in my apps with logic that is simple enough not to require unit testing. I am also the first to argue that excessive unit testing can become a liability.
Moreover, most views do not need a view model because they are at the wrong architectural level.
At my first job, we used C# on Windows and attended conference talks on unit testing. Meanwhile, in my spare time, I was developing for the Mac, and I read blogs from prominent Mac developers who argued against unit testing.
Support for unit testing was added to Xcode 5 only in 2013. That might sound like a long time ago now, but I started developing apps for the Mac in 2005, so I worked in an environment without unit testing for 8 years.
I have worked at startups where nothing was tested. However, I also worked at large companies where the testing requirements were stringent.
Moreover, I worked at companies that wanted to introduce unit testing into codebases that made it impossible, and the only solution was a complete rewrite, which management never allowed to happen. So we had to suck it up and live with bugs that were unfixable.
Pick your poison.
Unit testing is not the only argument in favor of view models. There are specific cases and patterns that require the implicit identity of objects, which SwiftUI views, being structures, cannot provide.
I will also provide a more complex example in the last section of this article.
Some developers who argue against view models acknowledge the need to embed some of the app’s business logic into classes that should typically reside in a view model for testability.
These are sometimes called aggregate models, and you can find examples in Apple’s sample apps Fruta and Food Truck.
Aggregate models are also shared objects, sometimes sitting just above controllers (or whatever you call those). They store data and encapsulate logic shared across several views.
Like other shared objects, they are distributed through the SwiftUI environment (although, curiously, in the Food Truck app, they are passed through initializers in the entire view hierarchy).
class Model: ObservableObject {
// ...
@Published var favoriteSmoothieIDs = Set()
// ...
}
struct FrutaApp: App {
@StateObject private var model = Model()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
// ...
}
}
struct FavoriteSmoothies: View {
@EnvironmentObject private var model: Model
var favoriteSmoothies: [Smoothie] {
model.favoriteSmoothieIDs.map { Smoothie(for: $0)! }
}
var body: some View {
SmoothieList(smoothies: favoriteSmoothies)
// ...
}
}
And that’s the crux of the matter. Being, like controllers, shared objects that encapsulate shared logic, they should not contain view-specific code, as I previously discussed.
In this chapter:
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEWhen implementing a view model, the general approach is to use an @Observable class on the main actor, as all data changes that trigger a UI update must occur on the main thread.
@MainActor @Observable class ViewModel {
var items: [Item] = []
var isLoading = false
var errorOccurred = false
private let networkController: NetworkController
init(networkController: NetworkController) {
self.networkController = networkController
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
items = try await networkController.fetchItems()
} catch {
errorOccurred = true
}
}
}
I removed the ViewState enumeration, as it was a bad practice, as I mentioned in a note above.
With the introduction of approachable concurrency in Swift 6.2, the @MainActor attribute is not needed anymore, as all code runs on the main actor by default.
This makes the view logic finally testable. We can stub the NetworkController to remove the need for a network connection.
class NetworkControllerStub: NetworkController {
override func fetchItems() async throws -> [Item] {
return [ Item() ]
}
}
@MainActor @Test func example() async throws {
let viewModel = ViewModel(networkController: NetworkControllerStub())
await viewModel.load()
#expect(!viewModel.isLoading)
#expect(!viewModel.errorOccurred)
#expect(viewModel.items.count == 1)
}
In specific cases, view models could also be implemented as Swift structures. However, there are many scenarios that require an object’s built-in identity, for example:
self parameter, like the trailing closure of a Task.Another argument against view models is that you can always test your views’ code using Xcode’s UI tests.
That’s not necessarily wrong. However, unit testing and UI testing are different and complementary, and the latter does not replace the former.
UI tests sit at the top of the testing pyramid, as illustrated in Apple’s documentation on testing.
UI tests test an entire user flow. While they are a useful tool, they involve a significant portion of your codebase, are more difficult to set up, slower to run, less precise, and prone to breaking. That’s why they are at the top of the pyramid, above integration tests.
Unit tests, on the other hand, are easier to write and maintain, quicker to run, and allow you to test granular behavior and edge cases that are harder or impossible to test through a UI test.
For example, I would never use a UI test to replace the simple unit test I showed above.
For these reasons, you should have many more unit tests than UI tests in your codebase.
Some of the arguments surrounding view models center on their instantiation.
According to Apple’s documentation, @Observable objects contained by @State properties should be created using the task(priority:_:) modifier, which is triggered only when the view appears on screen.
struct ItemsView: View {
@State private var viewModel: ViewModel!
@Environment(NetworkController.self) private var networkController
var body: some View {
NavigationStack {
Group {
if let viewModel {
if viewModel.isLoading {
// Display the loading view
} else if viewModel.errorOccurred {
// Display the error view
} else {
List(viewModel.items) { item in
// Display the list of restaurants
}
}
}
}
.task {
guard viewModel != nil else { return }
viewModel = ViewModel(networkController: networkController)
await viewModel.load()
}
}
}
}
I would not normally place a NavigationStack inside this view. But that’s a different topic altogether for another article. I added one here just for the sake of the example.
Some developers argue against this pattern because it requires making the view model property optional, with all the annoying related unwrapping and checks for nil. These can only be partially relieved by using an implicitly unwrapped optional (which is not necessary if you prefer a normal optional).
I agree that the extra code introduced by optionals can be annoying. However, other solutions are worse, as I will show below.
The only architecturally sound alternative is to instantiate view models inside coordinators and then inject them into their respective view through an initializer.
However, I would recommend that pattern only in complex SwiftUI apps. Coordinators can be useful for complex navigation and testing, but I wouldn’t introduce them only for the sake of removing Swift optionals from views.
One option is to instantiate a view model in the view’s initializer. However, this works when using the old approach to observable objects, which requires
ObservableObject with @Published properties;@StateObject stored property in the view.The reason this works is that the initializer of the @StateObject property wrapper has an escaping autoclosure, which SwiftUI runs only once.
@MainActor class ViewModel: ObservableObject {
// ...
}
struct ItemsView: View {
@StateObject private var viewModel: ViewModel
init() {
// This view model is instantiated only once, regardless of how
// many times the view's initializer is called.
self._viewModel = StateObject(wrappedValue: ViewModel())
}
// ...
}
However, the @State property wrapper currently does not have such an initializer. Following the same approach resets the view model every time the view initializer runs.
@MainActor @Observable class ViewModel {
// ...
}
struct ItemsView: View {
@State private var viewModel: ViewModel
init() {
// This view model is recreated every time the view's initializer
// is executed because of a user interface update.
self._viewModel = State(wrappedValue: ViewModel())
}
// ...
}
Even when using the old @StateObject property wrapper, instantiating a view model in the initializer of a view is only acceptable when the view model does not need to access any environment objects.
Environment objects are available only after a view initializer runs, when the body of the view is executed. That is why Apple documentation recommends using the task(priority:_:) modifier.
Some developers prefer to set up all required properties at initialization to avoid using optionals. While I share the sentiment, this leads to other problems.
To set up a view model with dependencies using an initializer, the dependencies or the view model instance itself must be passed as parameters. However, this pattern again violates several principles.
List with navigation links with a view with a view model as a destination, multiple instances would be created unnecessarily. (These last two points were a problem in the first days of SwiftUI, before the @StateObject property wrapper was introduced).The last problem can be avoided by using navigation links initialized with values managed by the navigationDestination(for:destination:) view modifier, which is located higher in the view hierarchy.
In this chapter:
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEOn the web, you can find many claims that MVVM goes against Apple frameworks, that Apple does not recommend MVVM, or even that they are explicitly against it.
I’ll start by saying that I don’t find that line of argument particularly interesting, as it’s just an argument from authority. Any argument for or against MVVM should rest solely on its merits. Apple is not the final arbiter of your app’s architecture.
However, since some do pursue this line of argument, I’ll address those claims as well.
The provided “proof” is usually that in some WWDC videos about SwiftUI, Apple “barely” mentions view models. For starters, barely is not zero, which would be the number if Apple were actively against view models.
We can easily disprove those claims by searching for “viewmodel” in the Apple Developer app and seeing it appear in several sessions, many as late as 2024 and 2025 (SwiftUI was introduced in 2019).
In fact, I would argue, if you care for these arguments anyway, that Apple introduced the @StateObject property wrapper at WWDC20 to explicitly allow developers to implement view models if they wanted to, since many were complaining about it (but that’s merely my opinion).
While this is not an endorsement of MVVM by Apple, it addresses the claims that Apple is somehow opposed to the use of view models or that they have no place in SwiftUI apps.
Another common argument against view models, particularly in relation to Apple frameworks, is that MVVM does not fit SwiftData.
Particularly, to my knowledge, the argument boils down to the fact that the @Query macro can only be used inside views, which is an argument I don’t find very compelling.
That argument rests solely on the idea that, for MVVM to be implemented correctly, any @Query property should be moved into a view model, along with the rest of the view logic.
That, however, is not necessary. For example, we can update our view model example by adding the template SwiftData code that comes with a new Xcode project.
@MainActor @Observable final class ViewModel {
// ...
private let modelContext: ModelContext
init(modelContext: ModelContext, networkController: NetworkController) {
self.modelContext = modelContext
self.networkController = networkController
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
// The view model can update the model context while the @Query
// stored property remains in the view.
let items = try await networkController.fetchItems()
try modelContext.transaction {
for item in items {
modelContext.insert(item)
}
}
} catch {
errorOccurred = true
}
}
}
This view model does not require a @Query property. You can leave that in the view, driving the interface updates when the underlying context changes.
struct ItemsView: View {
@State private var viewModel: ViewModel!
@Query private var items: [Item]
@Environment(\.modelContext) private var modelContext
@Environment(NetworkController.self) private var networkController
var body: some View {
NavigationStack {
Group {
if let viewModel {
if viewModel.isLoading {
// Display the loading view
} else if viewModel.errorOccurred {
// Display the error view
} else {
List(items) { item in
// Display the list of restaurants
}
}
}
}
.task {
guard viewModel != nil else { return }
viewModel = ViewModel(
modelContext: modelContext,
networkController: networkController
)
await viewModel.load()
}
}
}
}
If I had to guess, I think some developers might feel they need to move every @Query property into a view model to adhere to the single source of truth principle that Apple stressed since the release of SwiftUI.
However, that principle is not broken when leaving @Query properties in the view. The single source of truth is the underlying SwiftData ModelContext. A @Query property is a mere convenience to retrieve data from such a context.
In the view model example above, accessing the array of items is not needed. However, if you need that, you can do so with a computed property using a FetchDescriptor.
@MainActor @Observable final class ViewModel {
// ...
// This is a read-only computed property.
private var items: [Item] {
(try? modelContext.fetch(FetchDescriptor())) ?? []
}
//...
// Only the model context is modified. There is no need to update
// a stored property since the view still has a @Query property.
func deleteItems(offsets: IndexSet) {
for index in offsets {
modelContext.delete(items[index])
}
}
}
That is not a crime, like someone might want you to think.
Moreover, such a property is only for the view model code. That’s why I made it private. Nothing forces you to access it from the view once it has been created. You can keep using the @Query macro.
In this chapter:
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEDoes Apple ultimately recommend, or at least silently endorse, any specific design patterns?
Nowadays, Apple engineers don’t explicitly recommend any architectural pattern, since it’s not their job. I searched far and wide, and I could not find any explicit endorsement.
The only thing I can recall is a third-hand, recycled rumor that someone claimed to have been told by an Apple engineer in person that MVVM naturally fits SwiftUI, but I couldn’t even find the source of that rumor, and it would be a weak argument anyway.
I found an article about MVVM on LinkedIn written by an engineer currently working at Apple. However, the article was published before he joined Apple, so, in my opinion, it does not hold much weight.
Apple provides flexible frameworks, leaving architectural decisions to developers who know best what fits their requirements.
Critics of MVVM take this silence to claim that Apple’s WWDC talks silently endorse their personal interpretation of SwiftUI architecture, which they sometimes refer to as the MV pattern.
I think that claim is misleading. However, if we want to play that game, I can present stronger evidence of Apple endorsing the MVC pattern rather than anything else.
An often-cited source is a chart from Apple’s WWDC19 talk, “Data Flow Through SwiftUI,” where everyone marvels at the utter simplicity of this “new” pattern when compared to others.
The irony is that this is the same chart on page 10 of this document, which introduces MVC, and was created 40 years before the introduction of SwiftUI by Trygve Reenskaug.
The images in this section are slides from a video in my advanced course, Scalable Networking Architecture, titled “One Pattern to Rule Them All: Why MVC, MVVM, MV, and other variations are the same pattern.”
However, you don’t even need to dig that far, as the same diagram can currently be found, upside-down, on Wikipedia’s page for the MVC pattern.
If you want to claim that Apple endorses a design pattern for SwiftUI, that’s MVC.
This is not a surprise since Apple has explicitly endorsed the MVC pattern in the past. In fact, MVC is such a fundamental pattern in software development that any other successor is simply a rehash of the same ideas.
If you want to go a step further, you can even see an equivalent to the MVVM pattern appear in Apple’s documentation and frameworks.
If you read Apple’s guide about MVC carefully, you will find a section explaining how the controller layer can be split into model controllers, which are primarily concerned with the model layer, and view controllers, which are primarily concerned with the view layer.
The concept of view controllers was introduced long before the iPhone existed and was later explicitly integrated into UIKit. It does not take an expert in graph theory to recognize that the two patterns are equivalent.
Does this translate to SwiftUI? Some claim it doesn’t, and it’s fair to say that, since UIKit and SwiftUI are so different, the former does not affect the latter.
However, I believe that architectural lessons do not disappear when switching to a new UI framework, and I provided ample evidence in this article. In the end, you decide what’s best for your apps.
MVC and MVVM are examples of a multitier architecture that has typically three or four layers. Such an architecture can be expanded as needed, going, for example, from the three layers of MVC to the four of MVVM. In fact, I propose adding a fifth layer, which I refer to as the root layer.
As I showed above, the diagram Apple provided for SwiftUI’s data flow is based on the MVC pattern. From this perspective, one could argue that the MV pattern, which also uses controller-like shared objects, is essentially the same as MVC, albeit with a different name.
However, I believe their nature is different, and MV is an anti-pattern. The reason is that it does not merely propose a starting point, like MVC. Instead, it was conceived as a compression of layers, rather than an expansion, explicitly in opposition to MVVM.
As I have argued throughout the article, forcefully compressing responsibilities into the view layer leads to a violation of several fundamental software design principles.
While view models are not always required, and you might end up with an MVC architecture in some parts of your app, the MV pattern restricts you to this approach and recommends avoiding view models even when they are useful.
While this was a lengthy article with numerous examples, I kept them all simple to present each argument individually. However, the nuances of real-world software development are often lost when using toy examples.
Therefore, I would like to conclude the article with a more complex example taken from the final modules of my Scalable Networking Architecture course. This will demonstrate how the MVVM pattern is not only necessary to adhere to advanced software design principles, but also to implement standard code reuse practices such as modularity, reusability, and composition.
In the course, I built a networked app that interacts with the GitHub REST API, handling authentication, concurrent requests, rate limiting, offline caching, and error handling.
The app displays screens with a list of entities retrieved from the API. While I limit the example to users and repositories, the API allows fetching all sorts of lists, e.g., organizations, teams, issues, or pull requests.
While they differ in their appearance, these list screens obviously have a lot in common, such as loading and refreshing their content, and paging.
While different entities have different endpoint URLs, the sequence of API calls to fetch the list content is similar, requiring:
However, each list also has distinct requirements. For example, each user in the list requires two additional network requests to fetch all the data displayed in the list.
For these reasons, the SwiftUI view is a generic structure that contains all the common functionality, leaving the differences to be implemented by other types represented by Swift generics with type constraints.
struct RemoteListView<
Model: DecodableResource & Identifiable & Hashable & Placeholded,
Content: View,
ViewModel: RemoteListViewModel
>: View {
let url: DecodableURL<[Model]>
@State private var viewModel: ViewModel?
@Environment(NetworkController.self) private var networkController
typealias ToggleAction = () -> Void
var body: some View {
RemoteList(title: url.title, items: items, isLoadingNextPage: isLoadingNextPage) { item in
// ...
} loadNextPage: {
guard !(viewModel?.items.isEmpty ?? true) else { return }
Task { try? await viewModel?.fetchNextPage() }
}
.loading(viewModel?.items.isEmpty ?? true, redacted: true)
.task {
guard viewModel == nil else { return }
viewModel = ViewModel(url: url.url, networkLayer: networkController)
try? await viewModel?.fetch()
}
.refreshable {
try? await viewModel?.refresh()
}
}
var items: [Model] {
let placeholders: [Model] = Array(repeating: .placeholder, count: 3)
guard let items = viewModel?.items else { return placeholders }
return items.isEmpty ? placeholders : items
}
var isLoadingNextPage: Bool {
guard let viewModel else { return false }
return viewModel.isLoading && !viewModel.items.isEmpty
}
}
This is a pretty complicated piece of code. However, for this example, I want you to focus on the view model.
As I demonstrated at the beginning of the article, much of the view functionality is delegated to a view model object that can be tested separately.
The crucial part in this example, however, is the ViewModel generic, which allows the parent of the RemoteListView to specify a RemoteListViewModel subclass that implements the required API calls.
@MainActor @Observable class UsersListViewModel: RemoteListViewModel {
// ...
}
struct UsersListView: View {
let url: DecodableURL<[User]>
var body: some View {
RemoteListView<
User,
UserRow,
UsersListViewModel
>(url: url) { user, toggleAction in
UserRow(user: user, toggleFollowing: toggleAction)
}
}
}
@MainActor @Observable class ReposListView: RemoteListViewModel {
// ...
}
struct ReposListView: View {
let url: DecodableURL<[Repository]>
var body: some View {
RemoteListView<
Repository,
RepositoryRow,
ReposListViewModel
>(url: url) { repo, toggleAction in
RepositoryRow(repository: repo, toggleStarring: toggleAction)
}
}
}
Whenever the app needs to display a new list of entities, most of the functionality can be reused by implementing a new RemoteListViewModel subclass containing the specific implementation.
The MVVM pattern provides the flexibility of adding view models when it makes sense for maintainability, testability, and code reuse.
The arguments against using view models are weak, and the alternatives provided often violate several software development principles. While that is not an issue in simple apps, it presents several problems at scale.
Apple frameworks are flexible and do not impose or tacitly endorse a particular architecture, even though they are implicitly built over MVC, like every piece of software since the inception of the pattern.
Even with particular frameworks like SwiftData, view models can be used in conjunction with @Query stored properties in views.
Finally, strict design patterns like the MV pattern, which forcefully compress rather than expand the architectural layers of an app, strip away the unique characteristics of view models and create structural problems that cannot be solved by shared objects alone.
If you want to learn more about using view models and how to architect your SwiftUI apps, download my free guide below.
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.
The post Why Dismissing View Models in SwiftUI is Stifling your App’s Maintainability and Testability (And the Proven Principles for a Better Architecture) appeared first on Matteo Manferdini.
]]>As you create increasingly complex SwiftUI views, you may feel your code is turning into a tangled mess of nested stacks, layout view modifiers, and conditionals. In this article, we’ll explore how to leverage SwiftUI’s full toolkit—beyond just stacks—to build configurable views. You’ll learn to use built-in specialized views, view styles, and view builders for ... Read more
The post How to Create and Combine SwiftUI Views Without Getting Lost in Deep Nesting and Complex Layouts appeared first on Matteo Manferdini.
]]>As you create increasingly complex SwiftUI views, you may feel your code is turning into a tangled mess of nested stacks, layout view modifiers, and conditionals.
In this article, we’ll explore how to leverage SwiftUI’s full toolkit—beyond just stacks—to build configurable views. You’ll learn to use built-in specialized views, view styles, and view builders for idiomatic code that’s easier to customize.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEWhen creating SwiftUI views, the most natural approach is to pass data using stored properties that get reflected into the view’s memberwise initializer.
However, in specifically complex views, cramming everything into stored properties creates structural problems.
Let’s take, as an example, a view showing the stops in an itinerary, as you would show in a navigation or a public transport app.
The standard approach would be to define a view with a few stored properties and, in the body, use stacks and layout view modifiers to arrange and format the passed data.
struct Stop: View {
let title: String
let systemImage: String
var body: some View {
HStack(spacing: 16) {
Image(systemName: systemImage)
.foregroundStyle(.tint)
.font(.title2)
Text(title)
}
}
}
struct ItineraryView: View {
var body: some View {
List {
Stop(title: "Your location", systemImage: "circle.circle.fill")
Stop(title: "Walk 10 minutes", systemImage: "figure.walk")
Stop(title: "Marnixplein", systemImage: "train.side.front.car")
Stop(title: "Elandsgracht", systemImage: "mappin.and.ellipse.circle")
}
.listStyle(.plain)
}
}
#Preview {
ItineraryView()
}
This seems straightforward at first, but it’s already displaying some issues. The icons are not vertically centered as we would like, which would require additional layout code.
The problems start piling up when each row needs to display different content. If we follow the same approach, the view’s stored properties multiply, and we need to introduce optionals and conditional binding.
struct Stop: View {
let title: String
let systemImage: String
// These stored properties must be optional to remove them from
// initializers when they are not needed.
var time: String?
var action: (() -> Void)?
var body: some View {
// Displaying the accessory views on the right requires
// nested stacks and spacers.
HStack {
HStack(spacing: 16) {
// ...
}
Spacer()
// Each optional property requires optional binding.
if let time {
Text(time)
}
if let action {
Button(action: action) {
Image(systemName: "map")
}
}
}
}
}
struct ItineraryView: View {
var body: some View {
List {
Stop(title: "Your location", systemImage: "circle.circle.fill")
Stop(title: "Walk 10 minutes", systemImage: "figure.walk", action: {})
Stop(title: "Marnixplein", systemImage: "train.side.front.car")
Stop(
title: "Elandsgracht",
systemImage: "mappin.and.ellipse.circle",
time: "12:07"
)
}
.listStyle(.plain)
}
}
This approach is not intrinsically wrong. It is not a mistake to use nested stacks, spacers, and layout view modifiers. However, it is time-consuming to deal with SwiftUI’s layout process and determine the correct modifiers and spacing to align the views on the screen perfectly.
Moreover, the above approach becomes untenable when each row requires further customization, increasing the number of the view’s stored properties, the nesting of stacks, and the number of conditionals in its body.
Some developers might pass a whole model type to the view to reduce the number of stored properties. However, that would not solve the view’s structural problems, and it introduces other issues.
While passing model types into views may simplify their interface, it creates other issues, introducing tight coupling between the view and model layers, making views less reusable and harder to preview.
SwiftUI provides specialized views that handle the most common layout and styling automatically.
Instead of defaulting to stacks, always check the SwiftUI documentation for alternatives. These reduce nesting and make your code more declarative, easy to configure, and ultimately reusable.
The Label view removes the need to use an HStack with Image and Text every time you need to combine text and images. Moreover, it inherits its style from its container, eliminating the need for additional view modifiers.
struct Stop: View {
let title: String
let systemImage: String
var time: String?
var action: (() -> Void)?
var body: some View {
HStack {
Label(title, systemImage: systemImage)
Spacer()
// ...
}
}
}
In a List, the images of a label have the proper size, are vertically aligned along their central axis, and are tinted automatically, eliminating the need for additional view modifiers and guesswork.
Content paired with a label is also common enough for SwiftUI to provide a built-in view.
LabeledContent is ideal for settings or information rows, where a label is placed on one side and content on the other. It adapts to contexts such as Form or List without requiring extra layout code.
struct Stop: View {
let title: String
let systemImage: String
var time: String?
var action: (() -> Void)?
var body: some View {
LabeledContent {
if let time {
Text(time)
}
if let action {
Button(action: action) {
Image(systemName: "map")
}
}
} label: {
Label(title, systemImage: systemImage)
}
}
}
In this case, it might seem we didn’t gain anything as the HStack was merely replaced by a LabeledContent view.
However, we already had some improvements. The Spacer is gone, and the LabeledContent adds the standard styling to a row’s content in a List.
Moreover, LabeledContent adapts to its container, ensuring it displays appropriately when used in other contexts and platforms, such as on macOS. The other benefits of using LabeledContent will become more evident below.
The map button still does not appear as we intended. Luckily, we can easily customize it.
struct MapButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "map")
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.circle)
}
}
#Preview {
List {
LabeledContent {
MapButton(action: {})
} label: {
Text("Hello, World!")
}
}
.listStyle(.plain)
}
But how does that work exactly?
The buttonStyle(_:) view modifier takes a ButtonStyle value, which is then passed to the Button type through the environment.
We don’t need to customize our button further, but we can use the same concept elsewhere. Styles are supported by many of the built-in SwiftUI views.
To create the tags for public transport lines, we can use another built-in view. The GroupBox view provides a solid background with rounded corners, so we don’t need to mess with extra view modifiers.
struct TransportTag: View {
let line: String
let systemImage: String
var body: some View {
GroupBox {
Label(line, systemImage: systemImage)
}
.backgroundStyle(.tint.quinary)
}
}
#Preview {
List {
LabeledContent {
TransportTag(line: "5", systemImage: "tram")
} label: {
Text("Hello, World!")
}
}
.listStyle(.plain)
}
The tag, however, is too large and has excessive space between the image and the number because of the Label view.
There is no built-in label style that meets our needs, but we can build our own, creating a structure that conforms to the LabelStyle protocol. This is where we use stacks and other explicit layout views and modifiers to change a label’s appearance.
struct PublicTransportLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: 4) {
configuration.icon
configuration.title
}
.padding(-4)
.frame(height: 4)
}
}
extension LabelStyle where Self == PublicTransportLabelStyle {
static var publicTransport: PublicTransportLabelStyle { .init() }
}
struct TransportTag: View {
let line: String
let systemImage: String
var body: some View {
GroupBox {
Label(line, systemImage: systemImage)
.labelStyle(.publicTransport)
}
.backgroundStyle(.tint.quinary)
}
}
The LabeledContent view displays its content vertically, but we want our public transport tags displayed horizontally. We can also change that, creating a custom style structure that conforms to the LabeledContentStyle protocol.
struct InlineLabeledContentStyle: LabeledContentStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
Spacer()
configuration.content
.foregroundStyle(.secondary)
}
}
}
extension LabeledContentStyle where Self == InlineLabeledContentStyle {
static var inline: InlineLabeledContentStyle { .init() }
}
#Preview {
List {
LabeledContent {
TransportTag(line: "5", systemImage: "tram")
TransportTag(line: "7", systemImage: "bus")
} label: {
Text("Hello, World!")
}
}
.listStyle(.plain)
.labeledContentStyle(.inline)
}
Since styles are passed through the environment, we can even apply them to any view that uses Label and LabeledContent internally, even when we don’t have access to the view’s source code.
Moreover, you can easily apply styles conditionally, depending on external factors such as containers and platforms. This is how, for example, a List affects the aspects of the Label and LabeledContent views.
View styles and the SwiftUI environment allow us to achieve a high degree of customization. However, they don’t address all our problems.
Our Stop view still relies on multiple stored properties and conditional statements to decide which accessory to display. What we need is the ability to pass views as parameters to other views.
The solution is right in front of us. The Label, LabeledContent, and GroupBox views we have used above, like stacks and other containers, all accept other views through their initializers using view builders.
View builders are a concrete example of result builders, a Swift language feature that enables the construction of declarative, embedded domain-specific languages.
We can do the same in our Stop view.
struct Stop<Accessory: View>: View {
let title: String
let systemImage: String
@ViewBuilder let accessory: () -> Accessory
var body: some View {
LabeledContent {
accessory()
} label: {
Label(title, systemImage: systemImage)
}
}
}
Since the caller defines the return type of a view builder, you must express it using a Swift generic with a View type constraint.
Since not all stops in our ItineraryView require an accessory, we could make the accessory optional. However, that creates some compilation problems in some calls.
The alternative is to provide a concrete default value in a custom initializer.
extension Stop where Accessory == EmptyView {
init(title: String, systemImage: String) {
self.title = title
self.systemImage = systemImage
self.accessory = { EmptyView() }
}
}
Place custom initializers in a Swift extension if you don’t want to lose the memberwise initializer for structure types.
Thanks to built-in SwiftUI views, view styles, and view builders, our code views are now highly customizable, eliminating the need for extra stored properties and conditional statements.
struct ItineraryView: View {
var body: some View {
List {
Stop(title: "Your location", systemImage: "circle.circle.fill")
Stop(title: "Walk 10 minutes", systemImage: "figure.walk") {
MapButton(action: {})
}
Stop(title: "Marnixplein", systemImage: "train.side.front.car") {
TransportTag(line: "5", systemImage: "tram")
TransportTag(line: "7", systemImage: "bus")
}
.labeledContentStyle(.inline)
Stop(title: "Elandsgracht", systemImage: "mappin.and.ellipse.circle") {
Text("12:17")
}
}
.listStyle(.plain)
}
}
Instead of merely relying on standard Swift features like memberwise initializers and conditional statements, you can make your SwiftUI views more configurable and reusable by embracing all the tools offered by the framework.
These are only a few of the tools you can use to improve the structure of your SwiftUI apps. To go further, you need to adopt standard architectural patterns. I explain how in my free guide, which you can access below.
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.
The post How to Create and Combine SwiftUI Views Without Getting Lost in Deep Nesting and Complex Layouts appeared first on Matteo Manferdini.
]]>On the Internet, you can find plenty of SwiftUI tutorials that dive straight into building user interfaces without much consideration for underlying architectural principles. While these examples can get you started, they often lead to a common pitfall: massive SwiftUI views, i.e., views that are hundreds or even thousands of lines long, brimming with disparate ... Read more
The post From Massive SwiftUI Views to Reusable Components: The Root MVVM Approach to Modular Interfaces appeared first on Matteo Manferdini.
]]>On the Internet, you can find plenty of SwiftUI tutorials that dive straight into building user interfaces without much consideration for underlying architectural principles.
While these examples can get you started, they often lead to a common pitfall: massive SwiftUI views, i.e., views that are hundreds or even thousands of lines long, brimming with disparate logic.
In this article, I will explain why massive views are a problem and introduce a robust approach to building modular SwiftUI views that are more reusable, easier to understand, and instantly previewable in Xcode.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEMany developers, especially those new to SwiftUI, tend to create massive views, i.e., single SwiftUI View structs that accumulate a vast amount of code, handling everything from UI layout to complex business logic, networking, and data manipulation.
This approach, while seemingly simple at first, quickly leads to significant issues:
Here is an example of a view fetching the profile data for a user from the GitHub API. While it does not necessarily contain a massive number of lines of code, it nonetheless already displays many of the problems I listed above.
struct ProfileView: View {
// The view requires a live URL that points to a REST API.
let url: URL
// The user property requires fetching JSON data from a remote API.
@State private var user: User?
// The view requires a NetworkController instance to work.
@Environment(NetworkController.self) private var networkController
var body: some View {
// The body of the view can be rendered only after fetching data
// from the API.
if let user {
List {
VStack(alignment: .leading, spacing: 16) {
// AsyncImage requires an internet connection to display the
// user avatar.
AsyncImage(url: user.avatarURL) { image in
image
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.clipShape(Circle())
} placeholder: { ProgressView() }
Text(user.login)
.font(.title3)
.foregroundStyle(.secondary)
Text(user.bio)
}
}
.listStyle(.plain)
.navigationTitle(user.name)
.task {
await fetchUser()
}
}
}
// The fetchUser() method requires an internet connection to fetch
// the user data. It also cannot be unit tested.
func fetchUser() async {
// ...
}
}
Solving the entire problem requires using a design pattern like MVVM. In this article, I want to focus on a specific part that can immediately improve your development workflow.
My recommended approach to MVVM in SwiftUI divides the view layer into two distinct categories: root views and content views. This division promotes decoupling and cohesion within content views, making your app’s UI more manageable, testable, and reusable.
Content views, on the other hand, are smaller, highly focused SwiftUI views that focus solely on the visual layout of a specific part of the user interface.
They are designed to be highly reusable and easily testable because they are decoupled from the app’s model and business logic.
User, content views should receive only the primitive data types they need to display, e.g., String, Int, URL, etc.Many developers mistakenly believe that MVVM adds too much boilerplate code because they think each view requires a view model.
However, that is incorrect. Only root views require a view model, since they bridge the user interface and lower architectural layers. Content views, on the other hand, do not.
struct Header: View {
// The Header view only displays data with primitive data types.
// It does not know anything about the User type and how it is fetched.
let login: String
let name: String
let avatarURL: URL
let bio: String
var body: some View {
List {
VStack(alignment: .leading, spacing: 16) {
AsyncImage(url: avatarURL) { image in
image
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.clipShape(Circle())
} placeholder: { ProgressView() }
Text(login)
.font(.title3)
.foregroundStyle(.secondary)
Text(bio)
}
}
.listStyle(.plain)
.navigationTitle(name)
}
}
Because content views only rely on simple inputs, creating an Xcode Preview is trivial. You can simply provide mock data directly in the preview, allowing for quick visual verification without needing a full app setup or network connection.
#Preview {
NavigationStack {
// It is trivial to hardcode in the preview the data required by
// the Header view.
Header(
login: "matteom",
name: "Matteo Manferdini",
avatarURL: Bundle.main.url(forResource: "avatar", withExtension: "jpeg")!,
bio: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
)
}
}
Root views sit at the top of the view hierarchy for a given screen or major section of your app. They are the orchestrators, responsible for the app’s business logic and interacting with the app’s underlying data and services.
Here are the key responsibilities of a root view:
In essence, root views are purely glue types that bridge between the user interface and the lower layers of an app’s architecture.
Extracting the app’s business logic from root views requires a thorough application of the MVVM pattern and the application of more advanced design principles, such as the SOLID principles.
However, moving user interface code into content views already simplifies the role of root views.
struct ProfileView: View {
let url: URL
@State private var user: User?
@Environment(NetworkController.self) private var networkController
var body: some View {
// The body of a root view is free from user interface code,
// thus revealing the app's business logic
if let user {
Header(
login: user.login,
name: user.name,
avatarURL: user.avatarURL,
bio: user.bio
)
.task {
await fetchUser()
}
}
}
func fetchUser() async {
// ...
}
}
Embracing modularity in SwiftUI by distinguishing between root views and content views is a game-changer for building scalable and maintainable SwiftUI apps.
By adhering to the single responsibility principle and minimizing coupling, you create views that are inherently more reusable, significantly easier to understand, and a breeze to preview in Xcode.
If you’re ready to dive deeper into building SwiftUI apps with a solid architecture, download my free guide below to further enhance your understanding and skills.
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.
The post From Massive SwiftUI Views to Reusable Components: The Root MVVM Approach to Modular Interfaces appeared first on Matteo Manferdini.
]]>Since the introduction of SwiftUI, the MVVM pattern has become more relevant than ever. Many developers believe this particular pattern aligns well with the SwiftUI data flow. MVVM incorporates good ideas but also introduces problems due to varying interpretations of the pattern and its perceived rigidity. In this article, we’ll explore how MVVM fits into ... Read more
The post MVVM in SwiftUI for a Better Architecture [with Example] appeared first on Matteo Manferdini.
]]>Since the introduction of SwiftUI, the MVVM pattern has become more relevant than ever. Many developers believe this particular pattern aligns well with the SwiftUI data flow.
MVVM incorporates good ideas but also introduces problems due to varying interpretations of the pattern and its perceived rigidity.
In this article, we’ll explore how MVVM fits into SwiftUI, how to leverage its advantages, and how to navigate its challenges.
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEMVVM is an architectural pattern that helps you structure the code of a SwiftUI app by dividing it into three distinct roles.
The binder is a crucial component of MVVM. It synchronizes the view and view model layers, eliminating related boilerplate code.
The MVVM pattern is not exclusive to SwiftUI or iOS. Microsoft architects initially developed it, and it was integrated into iOS development years after the initial iOS SDK release.
In this chapter:
Apple doesn’t explicitly endorse any architectural pattern over another. However, SwiftUI is particularly fitted to the MVVM architecture.
A SwiftUI view model is usually implemented as an @Observable class held by a view within a @State property. It is connected to SwiftUI views that allow data input and user interaction through the @Binding property wrapper or action closures.
import SwiftUI
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
// Views connected to the viewModel property
}
}
@Observable class ViewModel {
// Properties and methods
}
While it is possible to implement a view model using a Swift struct, that is rarely a good option.
View models often interact with the global app state, permanent storage, and networking. These all need reference semantics and are usually implemented with objects.
Putting references inside value types is generally not recommended, so it’s always better to implement a view model as an @Observable class.
The MVVM pattern’s detractors commonly misunderstand it and believe it creates a large amount of useless boilerplate code because it requires attaching a view model to every view.
However, the problem is caused only by a too-strict adherence to MVVM prescriptions, which can cause problems with any other pattern.
Not all views require view models. Specifically, pure views, i.e., modular views that simply display information like Text or Image do not require a view model.
View models should be restricted to root views only when doing so has real benefits. You can read more about these concepts in my free guide on MVC and MVVM in SwiftUI.
The clearly defined roles in the SwiftUI MVVM architecture respect the separation of concerns design principle, which is crucial for maintaining well-organized and easily understandable/testable code.
Unfortunately, there’s a misconception among inexperienced developers that MVVM is an obsolete pattern and is no longer necessary in SwiftUI. However, multilayer architectural patterns like MVVM are unavoidable.
SwiftUI manages only the view layer. Without architectural patterns, code accumulates within views, violating fundamental software design principles like the SOLID principles. This results in massive monolithic views with code that is hard to reuse, maintain, and unit test.
The fact that SwiftUI automatically handles view updates does not justify abandoning software development best practices that have existed for decades across various platforms.
You might have seen an anonymous rant against MVVM on Apple’s forums that Google persistently puts at the top of search results.
Skimming through the thread, you can find gems like:
Don’t do Clean / SOLID anti-patterns, be an OOP engineer
developers must stop follow SOLID and “Uncle Bob” bad practices
Agile, SOLID and over testing works like a cancer for development teams.
No wonder that he then supports the so-called MV pattern. If you want to follow that advice, help yourself, but don’t say you weren’t warned.
To see an example of MVVM in SwiftUI, we will build a small app for Hacker News, a news website for developers similar to Reddit, which is known for its (debatable) quality. We will use its web API to fetch the top 10 news stories from the best stories page.
You can find the complete Xcode project on GitHub.
In this chapter:
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEWe will start by implementing our app’s model layer. Model types only contain the domain business logic, i.e., the app’s data and the code that manipulates it. They are agnostic of data storage, networking, and user interface presentation.
The Hacker News API uses an item entity to represent all its data, e.g., stories, comments, jobs, etc. Therefore, creating a corresponding Swift type is straightforward:
struct Item: Identifiable {
let id: Int
let commentCount: Int
let score: Int
let author: String
let title: String
let date: Date
let url: URL
}
extension Item: Decodable {
enum CodingKeys: String, CodingKey {
case id, score, title, url
case commentCount = "descendants"
case date = "time"
case author = "by"
}
}
Since the returned data is in JSON format, our Item struct conforms to the Decodable protocol and provides coding keys to map the JSON fields to our properties. You can read more in my article on parsing JSON in Swift.
Model types should not merely act as empty data containers. Data transformation code is an example of business logic belonging to the model layer of MVVM.
Typically, the model layer comprises Swift value types (structures and enumerations). However, that’s not a hard rule. For example, model types must be represented using classes when using SwiftData or Core Data.
Reusable content views constitute the second layer of MVVM that we will explore. Let’s start with a view representing a single entry in the top 10 most upvoted Hacker News posts.
struct Entry: View {
let title: String
let footnote: String
let score: Int
let commentCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8.0) {
Text(title)
.font(.headline)
Text(footnote)
.font(.footnote)
.foregroundColor(.secondary)
ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
Label(score.formatted(), systemImage: "arrowtriangle.up.circle")
.foregroundStyle(.blue)
Label(commentCount.formatted(), systemImage: "ellipses.bubble")
.foregroundStyle(.orange)
.padding(.leading, 96.0)
}
.font(.footnote)
.labelStyle(.titleAndIcon)
}
}
}
#Preview {
Entry(
title: "If buying isn't owning, piracy isn't stealing",
footnote: "pluralistic.net - 3 days ago - by jay_kyburz",
score: 1535,
commentCount: 773
)
}
The crucial aspect is that our view is entirely independent of our Item structure. Instead, it employs simple Swift types like Int and String.
Notice that the Entry view does not have separate stored properties for
It merely displays this information as a footnote text. This helps maintain types as loosely coupled as possible, minimizing the impact of changes on existing code.
The full view must display a list of stories. Most developers typically place the required state and networking code here. However, according to the MVVM pattern, that code belongs in a view model.
To retrieve the best stories from the API, we need to call endpoints:
extension ContentView {
@Observable @MainActor class ViewModel {
var stories: [Item] = []
func fetchTopStories() async throws {
let url = URL(string: "https://hacker-news.firebaseio.com/v0/beststories.json")!
let (data, _) = try await URLSession.shared.data(from: url)
let ids = try JSONDecoder().decode([Int].self, from: data)
stories = try await withThrowingTaskGroup(of: Item.self) { group in
for id in ids.prefix(10) {
group.addTask { try await self.fetchStory(withID: id) }
}
var stories: [Item] = []
for try await item in group {
stories.append(item)
}
return stories
}
}
private func fetchStory(withID id: Int) async throws -> Item {
let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")!
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return try decoder.decode(Item.self, from: data)
}
}
}
I usually place view model classes inside an extension for the respective view.
The fully scoped name of the class above is ContentView.ViewModel, which is the same name I would have used without scoping. However, this allows the shorter ViewModel type name to be used inside the ContentView.
The ViewModel class is @Observable, so any update to its stories property will trigger an update in the connected view. Placing this code outside of SwiftUI views simplifies testing.
The fetchTopStories() and fetchStory(withID:) methods employ Swift’s async await and URLSession to download data from the Hacker News API. Furthermore, the fetchTopStories() method utilizes a task group to parallelly retrieve the data for each story, creating distinct subtasks for each fetchStory(withID:) call.
In this simple example, I’ve placed all the networking code within the view model. However, for a more sophisticated SwiftUI app, a preferable approach to reusing code is constructing a separate Swift networking infrastructure for REST API calls.
Before the release of the Observation framework, view model classes conformed to the ObservableObject protocol, exposing their properties through the @Published wrapper. Moreover, the @StateObject wrapper was necessary inside SwiftUI views.
If your app doesn’t require support for iOS versions before 17, it’s recommended to migrate your codebase to use the Observation framework.
The final step in implementing MVVM in our SwiftUI app is connecting the view model to a view.
struct ContentView: View {
@State private var model = ViewModel()
var body: some View {
List(model.stories) { story in
Entry(story: story)
}
.listStyle(.plain)
.navigationTitle("News")
.task {
try? await model.fetchTopStories()
}
}
}
extension Entry {
init(story: Item) {
title = story.title
score = story.score
commentCount = story.commentCount
footnote = (story.url.host() ?? "")
+ " - \(story.date.formatted(.relative(presentation: .numeric)))"
+ " - by \(story.author)"
}
}
#Preview {
NavigationStack {
ContentView()
}
}
The ContentView type holds the ViewModel instance in a @State property. It then initiates network requests by invoking the fetchTopStories() method within its .task modifier and uses a List to show the contents of the stories property of the view model.
In the above example, the ViewModel object can be easily instantiated as the default value of the viewModel stored property because it has no dependencies.
When that’s the case, the view model initialization requires a different pattern. Particular care must be taken when using MVVM with SwiftData, where a proper understanding of how SwiftUI works can greatly simplify instantiation and dependency injection.
The Entry extension allows us to conveniently initialize the view using an Item value within the NewsView even though the Entry view initializer uses simple Swift value types.
Remember to include a NavigationStack in the app structure to display the navigation bar when running the app in the iOS simulator.
@main
struct HackerNewsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
You might notice a distinction between the Entry and ContentView types. While the former remains wholly decoupled from other app layers, the latter is linked to the ViewModel class and, consequently, to the Item structure.
The ContentView is what I call a root view. Although technically part of the view layer, it shoulders more responsibilities than a pure view, which solely displays data and enables interaction.
You can read more about these concepts in my free guide on MVC and MVVM in SwiftUI.
MVVM isn’t the sole architectural pattern available in SwiftUI.
I’ll begin this section with a disclaimer: it will reflect my strong opinions and critical perspectives on these alternative patterns.
However, sometimes, valuable ideas emerge that can be integrated into your app’s architecture. So, take everything I discuss here with a grain of salt.
In this chapter:
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDEMVVM and all other patterns are just variations of MVC, so the latter cannot be considered an alternative. When comparing the MVC pattern diagram, it’s apparent, even to non-experts in graph theory, that MVVM and MVC are nearly identical.
The fundamental disparity between MVC and MVVM in SwiftUI lies in the emphasis on controllers, objects shared across several views, versus view models, which are local objects controlling the behavior of a single view—usually representing a single screen in an iOS app.
However, in MVVM, the necessity for shared objects persists. These are often implemented as singletons, subsequently accessed by individual view models.
I consider singletons an anti-pattern, so I combine the MVVM and MVC patterns in my apps, modeling the view logic through view models and sharing controllers via dependency injection.
import SwiftUI
struct ContentView: View {
@State private var viewModel: ViewModel?
@Environment(Controller.self) private var controller
var body: some View {
Text("Hello, World!")
.task {
viewModel = ViewModel(controller: controller)
}
}
}
@Observable class ViewModel {
private let controller: Controller
init(controller: Controller) {
self.controller = controller
}
}
@Observable class Controller {
// ...
}
In such cases, view models must be initialized inside the task(priority:_:) view modifier, as recommended in Apple’s documentation.
Any developer complaining that you cannot access environment objects from view models or that passing them through an initializer is “too painful” should not be trusted.
The MV pattern (Model-View) seems to be a new contender to MVVM. Some developers assert that MVVM isn’t necessary for SwiftUI and that an app should primarily comprise views and the model.
I consider the MV pattern to be an anti-pattern. It goes against pretty much every software design principle and makes code untestable. This is a high price for the supposed benefit of having a little less boilerplate code.
This concept is occasionally likened to the Elm architecture and often stems from a slide in Apple’s initial SwiftUI presentation.
These show a complete lack of knowledge about design patterns and best practices, paired with the arrogance that a new UI framework somehow necessitates throwing away decades of hard-learned lessons.
Trygve Reenskaug used the same diagram for the MVC pattern 40 years earlier, and it can be easily found on Wikipedia.
Apple developers claim that SwiftUI is architecture-agnostic. However, there is no escaping that any architectural design pattern rests its foundations on MVC.
When you poke, the MV pattern reveals the need for objects shared across views through singletons or as SwiftUI environment objects. You might label those handlers, managers, stores, services, or any other fancy name, but fundamentally, they remain controllers.
Moreover, forgoing a well-defined design pattern leads to these objects lacking distinct roles. Throwing away layers when the general approach has always been adding more results in a messy codebase and model types that encompass responsibilities belonging to other layers.
A family of patterns, including VIP (View-Interactor-Presenter) and VIPER (View-Interactor-Presenter-Entity-Router), stems from what’s commonly known as Uncle Bob’s Clean Architecture.
Much could be said about these patterns, but as I said above, they are all a rehash of MVC with shuffled roles.
One significant flaw of theirs is the proliferation of protocols and generics. While these are necessary in complex apps, every abstraction incurs a cognitive cost. Abstractions should only be used when truly beneficial, not merely because a pattern prescribes them.
However, they cab also introduce ideas worth exploring. For example, the router idea from VIPER can be integrated into MVVM in the form of coordinators to better manage the navigation of a complex SwiftUI app.
Among the options discussed here, The Composable Architecture (TCA) seems to be gaining popularity, especially with support from a dedicated open-source library.
I believe Redux-like architectural patterns, such as TCA, are a terrible choice. They impose functional programming paradigms onto a language and a framework that are not functional but heavily reliant on imperative code and state.
The primary issue with this approach is that it concentrates the entire application state within a single monolithic structure, resulting in tightly coupled code.
Moreover, any change to the data in such a structure can trigger view updates across the entire view hierarchy, even in views unrelated to the updated state components.
Another problem arises from reducers, which introduce substantial boilerplate code typically implemented using numerous Swift switches. This code blatantly violates the Open-closed principle.
The MVVM pattern introduces a crucial architectural idea: the necessity of dedicated objects for views in an iOS app, namely the view models of MVVM.
This article covers only a portion of an app’s architecture. As previously mentioned, complex apps often require global objects.
Relying solely on the MVVM pattern proves overly restrictive. Attempting to force all code into view models will likely lead to complications.
Additionally, a strict adherence to MVVM fails to address specific problems. For instance, why is the NavigationStack in the above example located in the main App structure? What happens in apps with intricate navigation?
Furthermore, adding objects to views can easily disrupt Xcode previews.
An extra layer—the root layer—is required to resolve these issues. For more information, access my free guide below.
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.
The post MVVM in SwiftUI for a Better Architecture [with Example] appeared first on Matteo Manferdini.
]]>