Avoiding massive SwiftUI views
Discover page available: SwiftUIWhen writing any form of UI code, it can often be challenging to decide when and how to split various view implementations up into smaller pieces. It’s so easy to end up with views that each represent an entire screen or feature — leading to code that’s often hard to change, refactor and reuse.
For UIKit-based apps (and to some extent AppKit-based ones, too), a very common manifestation of this problem is the “Massive View Controller” syndrome — which is when a view controller ends up taking on too many responsibilities, resulting in a massive implementation, both in terms of scope and line count.
Now, as we’re collectively moving towards SwiftUI as the go-to framework for building UIs for all of Apple’s platforms, it might first seem that this problem will simply go away. No view controllers, no problems, right? However, while SwiftUI’s overall design does encourage us to write more composable, decoupled code by default — it still requires us to design and factor our view code in a way that doesn’t put too many responsibilities on individual types.
This week, let’s explore that topic, and take a look at a few different techniques that can be useful in order to avoid trading Massive View Controllers for Massive Views.
Extract, reuse, repeat
Since SwiftUI views are not concrete representations of pixels on screen, but rather lightweight descriptions of the various views that we wish to render, they often lend themselves quite well to being extracted into smaller pieces that can then be reused within various contexts.
For example, let’s say that we’re working on an app for browsing movies. To render a list of movies, we’ve built a MovieList
view — which observes a view model and renders its various subviews like this:
struct MovieList: View {
@ObservedObject var viewModel: MovieListViewModel
@Binding var selectedMovie: Movie?
var body: some View {
List(viewModel.movies, selection: $selectedMovie) { movie in
HStack {
Image(uiImage: movie.image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 100)
VStack(alignment: .leading) {
Text(movie.name).font(.headline)
HStack {
Image(systemName: "person")
Text("Director:")
}.foregroundColor(.secondary)
Text(movie.director)
HStack {
Image(systemName: "square.grid.2x2")
Text("Genre:")
}.foregroundColor(.secondary)
Text(movie.genre)
}
}
}
}
}
If we only look at the above view’s line count, it isn’t really massive at all. However, it’s arguably quite difficult to quickly get a grasp of what the resulting UI will look like, given that we’re currently constructing all of our view’s various parts in one single place — and that problem is likely to keep growing as we keep adding new UI variations and features.
Instead, let’s see if we can structure the above view as a collection of individual components, rather than as as a single unit. That way, we’d both be able to individually reuse those components within other views, and we should also be able to make our UI code read much nicer.
Let’s start with our Image
, which doesn’t really warrant a new View
implementation — since we’re only applying a set of modifiers to make each image render as a smaller “thumbnail”. So, like we took a look at in “Configuring SwiftUI views”, let’s instead write an extension that groups those modifiers together in order to make them more semantically meaningful:
extension Image {
func asThumbnail(withMaxWidth maxWidth: CGFloat = 100) -> some View {
resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: maxWidth)
}
}
Next, let’s refactor the subviews that render our two main pieces of information — the movie’s director and genre — into a single reusable component called InfoView
:
struct InfoView: View {
var icon: Image
var title: String
var text: String
var body: some View {
VStack(alignment: .leading) {
HStack {
icon
Text(title)
}.foregroundColor(.secondary)
Text(text)
}
}
}
During the refactoring process, we also took the opportunity to make the above view completely unaware of its underlying model — as it now simply renders an icon, a title and a text, rather than a Movie
model. Check out “Preventing views from being model aware in Swift” to learn more about that approach.
The above may seem like minor changes in the grand scheme of things, but if we now go back to our MovieList
and update it to use our new components, we can see that we’ve actually made its implementation quite a lot easier to read:
struct MovieList: View {
@ObservedObject var viewModel: MovieListViewModel
@Binding var selectedMovie: Movie?
var body: some View {
List(viewModel.movies, selection: $selectedMovie) { movie in
HStack {
Image(uiImage: movie.image).asThumbnail()
VStack(alignment: .leading) {
Text(movie.name).font(.headline)
InfoView(
icon: Image(systemName: "person"),
title: "Director:",
text: movie.director
)
InfoView(
icon: Image(systemName: "square.grid.2x2"),
title: "Genre:",
text: movie.genre
)
}
}
}
}
}
Even though we just barely reduced our list view’s line count during or refactor, we’ve now set its implementation up to grow in a much more maintainable fashion, as we can now iterate on each of its individual components separately — which typically goes a long way to prevent views from becoming massive.
However, let’s not stop there, because the beauty of SwiftUI’s highly composable design is that we can keep splitting our UI up into separate pieces until we’ve reached a level of separation that we’re completely happy with. For example, rather than having MovieList
itself be responsible for configuring each of its rows — we could encapsulate all those subviews into yet another component, like this:
struct MovieRow: View {
var movie: Movie
var body: some View {
HStack {
Image(uiImage: movie.image).thumbnail()
VStack(alignment: .leading) {
Text(movie.name).font(.headline)
InfoView(
icon: Image(systemName: "person"),
title: "Director:",
text: movie.director
)
InfoView(
icon: Image(systemName: "square.grid.2x2"),
title: "Genre:",
text: movie.genre
)
}
}
}
}
While we could’ve also made the above MovieRow
model-agnostic, it’s questionable whether doing so would be worth it in this case — as it essentially acts as a “composition layer” between our core components (such as InfoView
) and our Movie
model.
With the above in place, we can now go back to MovieList
one more time, and heavily simplify its implementation. It can now simply be concerned with one single task — being a list — and let its subviews configure and manage themselves:
struct MovieList: View {
@ObservedObject var viewModel: MovieListViewModel
@Binding var selectedMovie: Movie?
var body: some View {
List(viewModel.movies,
selection: $selectedMovie,
rowContent: MovieRow.init
)
}
}
Since SwiftUI will automatically re-render each view whenever any of its data dependencies change, we don’t need to manually manage any form of state between our MovieList
and its subviews — it’s all being taken care of by the framework.
Binding mutable state
However, what if we need one of our subviews to be able to mutate some form of state that’s owned by its parent? While SwiftUI always propagates state changes downward through our view hierarchy, to also make changes upward, we’ll need to create two-way bindings that’ll let updates flow in both directions.
Let’s now say that we’re working on an app for ordering some form of products, and that we’re looking to refactor our main OrderForm
to become more modular — similar to what we did to our MovieList
view above. This view uses SwiftUI’s built-in Form
API to render a series of sections that each contain input controls for mutating the user’s current Order
— like this:
struct OrderForm: View {
@ObservedObject var productManager: ProductManager
var handler: (Order) -> Void
@State private var order = Order()
var body: some View {
NavigationView {
Form {
Section(header: Text("Shipping address")) {
TextField("Name", text: $order.recipient)
TextField("Address", text: $order.address)
TextField("Country", text: $order.country)
}
Section(header: Text("Product")) {
Picker(
selection: $order.product,
label: Text("Select product"),
content: {
ForEach(productManager.products) { product in
Text(product.name).tag(product)
}
}
)
}
...
Button(
action: { self.handler(self.order) },
label: { Text("Place order") }
)
}
}
}
}
Note the use of NavigationView
above, which is needed when using the default Picker
style on iOS — as such a picker pushes its view for selecting an option onto the current navigation stack.
So how could we split the above view up while still enabling each part to mutate the same Order
state? Let’s take some inspiration from SwiftUI’s built-in views, and use the @Binding
property wrapper to create two-way bindings between our new subviews and their parent’s state. Here’s how we might do just that when extracting our form’s “Shipping address” section:
struct ShippingAddressFormSection: View {
@Binding var order: Order
var body: some View {
Section(header: Text("Shipping address")) {
TextField("Name", text: $order.recipient)
TextField("Address", text: $order.address)
TextField("Country", text: $order.country)
}
}
}
Above we give our new stand-alone section access to the complete Order
model, as it needs to mutate several of its properties. However, for the “Product” section, we’ll take a slightly different approach — and only give it access to an array of products to pick from, and a binding Product
value to assign the user’s selection to:
struct ProductPickerFormSection: View {
var products: [Product]
@Binding var selection: Product
var body: some View {
Section(header: Text("Product")) {
Picker(selection: $selection, label: Text("Select product")) {
ForEach(products) { product in
Text(product.name).tag(product)
}
}
}
}
}
Once we’ve extracted all of our sections like we did above, we can then go back to our OrderForm
and simply have it pass binding references to its own Order
state when creating its sections — like this:
struct OrderForm: View {
@ObservedObject var productManager: ProductManager
var handler: (Order) -> Void
@State private var order = Order()
var body: some View {
NavigationView {
Form {
ShippingAddressFormSection(order: $order)
ProductPickerFormSection(
products: productManager.products,
selection: $order.product
)
...
Button(
action: { self.handler(self.order) },
label: { Text("Place order") }
)
}
}
}
}
The fact that we can turn any form of local State
property into a two-way Binding
value, simply by prefixing it with $
(which accesses its projected value), is incredibly powerful — as it gives us complete freedom as to how we split our views up, even if those subviews then need to mutate their parent’s state.
Out-of-body delegation
Finally, let’s take a look at how we could break some of our larger View
implementations up by delegating certain tasks to external objects. For example, let’s say that we’re building a HomeView
that acts as the initial navigation destination for an app. It shows a menu implemented as a List
, which in turn contains a series of rows each wrapped within a NavigationLink
, to enable new views to be pushed onto the navigation stack — like this:
struct HomeView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: ...) {
MenuRow(title: "Catalog", icon: .browse)
}
NavigationLink(destination: ...) {
MenuRow(title: "Recommendations", icon: .star)
}
NavigationLink(destination: ...) {
MenuRow(title: "Profile", icon: .user)
}
...
}.navigationBarTitle("Home")
}
}
}
Now the question is, how should we create the above destination
views? One option would of course be to create each destination inline within the above view itself — however, doing so would not only risk making our HomeView
quite massive, it would also require it to carry all of the dependencies needed by each of its destinations.
While we’ll take a closer look at various ways of managing dependencies when using SwiftUI in much more detail in upcoming articles, one way to prevent HomeView
from taking on too many responsibilities in this case would be to introduce a separate object that can handle the complexity of creating all of our destinations.
Like we took a look at in “Dependency injection using factories in Swift” and “Managing objects using Locks and Keys in Swift”, using the factory pattern can be a great way to decouple various views and screens from the rest of an app — as it lets us move the creation of navigation destinations away from where the navigation is triggered. Here’s how we might do exactly that for our HomeView
:
struct HomeView: View {
var factory: HomeViewFactory
var body: some View {
NavigationView {
List {
NavigationLink(destination: factory.makeCatalogView()) {
MenuRow(title: "Catalog", icon: .browse)
}
NavigationLink(destination: factory.makeRecommendationsView()) {
MenuRow(title: "Recommendations", icon: .star)
}
NavigationLink(destination: factory.makeProfileView()) {
MenuRow(title: "Profile", icon: .user)
}
...
}.navigationBarTitle("Home")
}
}
}
The HomeViewFactory
that we use above can then contain all of the dependencies needed by our destinations, and be responsible for setting up each destination view in a way that leaves HomeView
completely unaware of those details:
struct HomeViewFactory {
var database: Database
var networkController: NetworkController
...
func makeCatalogView() -> some View {
let viewModel = CatalogViewModel(database: database, ...)
return CatalogView(viewModel: viewModel)
}
func makeRecommendationsView() -> some View {
...
}
func makeProfileView() -> some View {
...
}
}
While we probably don’t want to move all of our view creation code into factory types, delegating the setup of complex navigation hierarchies to some form of external object can often be a good idea, and the same is true for other types of complex tasks as well — such as event handling, input validation, and so on.
Conclusion
The fact that SwiftUI was designed with composition and two-way data binding in mind gives us an enormous amount of flexibility when it comes to how we structure our views and their various components. By starting to break our UIs up into smaller building blocks early, and by making our lower-level components as unaware of our domain-specific models as possible, we can often achieve a very flexible setup that lets us tweak and iterate on our UI with ease.
While we should always think twice before introducing new abstractions, delegating certain tasks to external objects could also help us make our top-level views simpler — as doing so could let them simply focus on connecting a set of UI components to a specific piece of state, and to mutate that state according to user input.
What do you think? How do you plan to avoid massive SwiftUI views, both now and as you continue to iterate on your code base? Let me know — along with your questions, comments and feedback — either on Twitter or via email.
Thanks for reading! 🚀