DEV Community

loading...
Cover image for A guide to sensible composition in SwiftUI
Triplebyte

A guide to sensible composition in SwiftUI

danielwbean profile image Daniel Bean Originally published at triplebyte.com ・7 min read

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }

}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)

pic
Editor guide