From SwiftUI Views to Reusable Components: The Root MVVM Way

4 months ago 2

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.

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

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

DOWNLOAD THE FREE GUIDE

Table of contents

Massive views are the cause of most architectural problems in SwiftUI apps 

Many 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:

  • Massive views contain untestable code: When a single view is responsible for too much, isolating and testing specific pieces of its functionality becomes incredibly difficult.
  • Massive views are sometimes impossible to preview in Xcode: If your views are tightly coupled to external dependencies, such as network controllers or complex data models, setting up a preview environment can be complicated, or even impossible.
  • Massive views are hard to understand and maintain: New features or bug fixes often require sifting through an overwhelming amount of code, which increases the risk of introducing new issues and slows down the development process.

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 { // ... } }

Creating modular and reusable SwiftUI views by splitting the view layer into two

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 should only be concerned with the app’s user interface

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.

  • They receive simple Swift types and are decoupled from the app’s model. Instead of directly taking complex model objects, e.g., User, content views should receive only the primitive data types they need to display, e.g., String, Int, URL, etc.
  • They pass data to their parent using bindings and action closures: If a content view needs to report user interaction back to its parent (a root view or another content view), it does so using bindings for mutable state or action closures for event handling.
Misconception

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 serve as a bridge between content views and the lower layers of an app’s architecture

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:

  • They receive model types from other views.
  • They manage view models.
  • They receive controllers through the SwiftUI environment.
  • They interpret user actions into the app’s business logic.
  • They perform network requests.
  • They manage navigation.

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 { // ... } }

Conclusions

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.

SwiftUI App Architecture: Design Patterns and Best Practices

It's easy to make an app by throwing some code together. But without best practices and robust architecture, you soon end up with unmanageable spaghetti code. In this guide I'll show you how to properly structure SwiftUI apps.

GET THE FREE BOOK NOW

Read Entire Article