A guide to sensible composition in SwiftUI
Daniel Bean
Posted on January 25, 2021
This article first appeared on the Triplebyte blog and was written by Joseph Pacheco. Joseph is a software engineer who has conducted over 1,400 technical interviews for everything from back-end, to mobile, to low-level systems, and beyond. He’s seen every quirk, hiccup, and one-of-a-kind strength you can think of and wants to share what he’s learned to help engineers grow.
The ease with which you can compose views in SwiftUI is a literal miracle. While composition is still doable (and valuable) in UIKit, the level of flexibility is at least an order of magnitude more rich.
But all this freedom brings some tough choices. How often should we be composing views? How many files is too many? Should we throw a component in a variable or an entirely separate named struct?
Here's a few thoughts to guide your choices.
Clarity is your sun
The single most important principle when deciding how to compose your views is clarity. It might seem that goes without saying, but it's easy to get your wires crossed.
There's a difference between optimizing for clarity and adding an explicit category to every possible grouping of views. The former makes your view hierarchy easier to reason about, while the latter just adds noise.
I'll dive into examples below, but if you find yourself feeling a compulsive itch to label stuff, take a step back and think again!
Reuse is your moon
The other reason we employ composition in SwiftUI is to avoid unnecessary duplication of code. In other words, reuse your views.
This can add to the clarity of your view hierarchy but it's also a distinct concern. Not reusing code has additional trade-offs beyond making your code less clear. It can also make it a nightmare to update, while also introducing bugs that come with forgetting to change all places in which a piece of view code has been copied and pasted.
Because of SwiftUI, composition on Apple platforms is easier than it ever has been before. So when you're tempted to copy/paste, remember it might be almost trivial to architect things in a way that your future self won't be cursing your name.
Variables (and functions) are your first line of defense
When _compo_sing our views (e.g. breaking them down into digestible _compo_nents) our first line of defense is variables. This means taking chunks of functionality out of your view's body and moving them elsewhere in the view's overall definition.
Variables are wonderful and definitely have their place, but they can also be misused.
For example, consider Apple's Reminders app. In particular, take a look at the view that displays a list of reminders:
If I were to implement this view myself, it might look something like this:
/// A view that displays a list of reminders
struct RemindersList: View {
/// The reminders belonging to this list
@Binding var reminders: [Reminder]
/// The body of the view
var body: some View {
NavigationView {
List {
ForEach(reminders) { reminder in
HStack {
Button(action: {
if let index = reminders.firstIndex(of: reminder) {
reminders[index].isComplete.toggle()
}
}, label: {
Image(systemName: reminder.isComplete ? "largecircle.fill.circle" : "circle")
.imageScale(.large)
})
VStack {
Text(reminder.title)
}
}
.buttonStyle(PlainButtonStyle())
}
Button(action: {
// TODO: Add a new reminder
}, label: {
HStack {
Image(systemName: "plus.circle.fill")
Text("New Reminder")
}
})
}
.listStyle(PlainListStyle())
.navigationTitle("My Reminders")
}
}
}
While this technically does the trick, it's also a nasty mess. It takes work to reason through, and the view isn't even that long or complicated. In order to see which views belong to what component, you have to look at each line of code and think. For example, does that HStack
do something for the overall list, or is that associated with a reminder within the list? Is the second Button
part of each reminder?
This is pretty bad, so we turn to some simple variables to clean things up:
/// A view that displays a list of reminders
struct RemindersList: View {
/// The reminders belonging to this list
@Binding var reminders: [Reminder]
/// The body of the view
var body: some View {
NavigationView {
List {
reminderListItems
newReminderButton
}
.listStyle(PlainListStyle())
.navigationTitle("My Reminders")
}
}
/// The views representing each reminder in the list
private var reminderListItems: some View {
ForEach(reminders) { reminder in
HStack {
Button(action: {
if let index = reminders.firstIndex(of: reminder) {
reminders[index].isComplete.toggle()
}
}, label: {
Image(systemName: reminder.isComplete ? "largecircle.fill.circle" : "circle")
.imageScale(.large)
})
VStack {
Text(reminder.title)
}
}
.buttonStyle(PlainButtonStyle())
}
}
/// A button that adds a new reminder to the bottom of the list
private var newReminderButton: some View {
Button(action: {
// TODO: Add a new reminder
}, label: {
HStack {
Image(systemName: "plus.circle.fill")
Text("New Reminder")
}
})
}
}
Already, the body of the view is vastly easier to reason about. It's now clear that the list has two bunches of things in it: the existing reminders and the button to add a new reminder at the bottom. Even if you stopped your improvements here, it would be way better than before.
That said, these new variables themselves are still begging to be broken down further, especially the ForEach
that holds the existing items. The layout for each individual item really should be its own thing. The problem is that each row relies on the current reminder to work, so we need a function rather than a simple variable:
/// A view that displays a list of reminders
struct RemindersList: View {
/// The reminders belonging to this list
@Binding var reminders: [Reminder]
/// The body of the view
var body: some View {
NavigationView {
List {
reminderListItems
newReminderButton
}
.listStyle(PlainListStyle())
.navigationTitle("My Reminders")
}
}
/// The views representing each reminder in the list
private var reminderListItems: some View {
ForEach(reminders) { reminder in
view(for: reminder) {
if let index = reminders.firstIndex(of: reminder) {
reminders[index].isComplete.toggle()
}
}
}
}
/// Return a view for the given reminder
private func view(for reminder: Reminder, _ didTapButton: @escaping () -> Void) -> some View {
HStack {
Button(action: {
didTapButton()
}, label: {
Image(systemName: reminder.isComplete ? "largecircle.fill.circle" : "circle")
.imageScale(.large)
})
VStack {
Text(reminder.title)
}
}
.buttonStyle(PlainButtonStyle())
}
/// A button that adds a new reminder to the bottom of the list
private var newReminderButton: some View {
Button(action: {
// TODO: Add a new reminder
}, label: {
HStack {
Image(systemName: "plus.circle.fill")
Text("New Reminder")
}
})
}
}
In this iteration, we've created a function that returns the view for a particular reminder and used that within the remindersListItems
variable. While this does add clarity, we're starting to run up against the limitations of functions and variables.
First of all, the code in this file is starting to get awfully long. While we've clustered things more effectively, we still haven't done a great job of making the file's scope easier to reason about.
Likewise, there's something that's not quite right about the function that returns a reminder view. In particular, the closure at the end and function syntax when using it within reminderListItems
just don't feel so natural.
We need additional techniques.
Define separate structs as complexity demands
The obvious solution to address the awkwardness of the function in the example we’re working with is to turn each item into its own view. This both allows us to move a nice chunk of code outside of the list view while also making the API a bit clearer.
First, it allows me to keep the number of variables in my RemindersList
to one per top level component in the body, which feels natural and clean:
/// A view that displays a list of reminders
struct RemindersList: View {
/// The reminders belonging to this list
@Binding var reminders: [Reminder]
/// The body of the view
var body: some View {
NavigationView {
List {
reminderListItems
newReminderButton
}
.listStyle(PlainListStyle())
.navigationTitle("My Reminders")
}
}
/// The views representing each reminder in the list
private var reminderListItems: some View {
ForEach(reminders) { reminder in
ReminderListItem(reminder: reminder) {
if let index = reminders.firstIndex(of: reminder) {
reminders[index].isComplete.toggle()
}
}
}
}
/// A button that adds a new reminder to the bottom of the list
private var newReminderButton: some View {
Button(action: {
// TODO: Add a new reminder
}, label: {
HStack {
Image(systemName: "plus.circle.fill")
Text("New Reminder")
}
})
}
}
Second, it allows me to more meaningfully compose each item in the list into an API with its own component variables:
/// A view that displays a reminder in the context of a list of reminders
struct ReminderListItem: View {
/// A closure trigger when the status button has been tapped
typealias DidTapStatusButtonClosure = () -> Void
/// The reminder displayed
let reminder: Reminder
/// The closure trigger when the status button has been tapped
let didTapStatusButton: DidTapStatusButtonClosure
/// The body of the view
var body: some View {
HStack {
statusButton
titleView
}
.buttonStyle(PlainButtonStyle())
}
/// The button that determines the reminder's status
private var statusButton: some View {
Button(action: {
didTapStatusButton()
}, label: {
Image(systemName: reminder.isComplete ? "largecircle.fill.circle" : "circle")
.imageScale(.large)
})
}
/// The view displaying the reminder's title
private var titleView: some View {
Text(reminder.title)
}
}
The question remains, should we continue to break this down further? And the answer would be no.
If you look at the ReminderListItem
, the body is very straight-forwardly clear. It's an HStack
with two logical components: the button and the title. A quick glance tells us all we need to know.
On top of that, moving the button out into it's own struct doesn't really add much value. Declaring it within the variable isn't very long, and using the variable "statusButton" in the view's body is logical and natural. In fact, creating a separate view would just add more complexity and work for us, so we skip it.
Finally, we could have put Text(reminder.title)
right in the body, but adding it to a variable of the same form as the button creates nice consistency and better glance effect. And since in a real app that title would almost certainly have view modifiers attached, it gets that noise out of the body.
Eliminate noise beyond the scope of your views
There is, however, another thing we should do to clean up our RemindersList
that's not necessarily obvious: Get rid of that NavigationView
.
Why? The NavigationView
is not really a part of the reminders list. It's a top-level controller that handles navigation from view to view. It's best defined in the parent of our RemindersList
so we don't put ourselves in a situation where we declare more than one.
Besides, we want to keep our view definitions limited to the level of abstraction that naturally follows from their name. Navigating between views is not inherent in the meaning of a list of reminders. Likewise, we earlier removed a function returning each reminder view so all component variables could be more naturally tied directly to something declared in the body. The variables and functions you use should be limited in number and not nested in scope. Your variables should be flat.
Don't over compose
Finally, there's one more consideration to keep in mind: Don't over compose. The body of your views should always be a clear snapshot of the views hierarchy, and it should not be needlessly broken down. For example, you would never want to see a body with just one variable.
Now, go forward and create cleaner and more sensible SwiftUI compositions!
Triplebyte helps engineers assess and showcase their technical skills and connects them with great opportunities. You can get started here.
Posted on January 25, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.