LAST CHANCE: Save 50% on all my Swift books and bundles! >>

5 Steps to Better SwiftUI Views

Learn to make your SwiftUI views smaller, simpler, and more reusable.

Paul Hudson       @twostraws

As SwiftUI projects grow more complex, it’s common to find your views get larger and larger as you cram more functionality into them. In this article I’ll walk you through the underlying problem, and demonstrate five steps you can take to make your views simpler, smaller, and smarter – all demonstrated using a real SwiftUI project, so you have actual code to follow along.

You can get the project source code, including both before and after versions, from this link.

  • This article is also available as a video if you prefer. The content is the same, so use whichever is easiest for you.

Still here? Okay, let’s get to it…

Click here to save 50% on all my books and bundles!

What’s the problem?

This is a really challenging time for SwiftUI because at this point we’re kind of past the novelty of SwiftUI itself – we’re used to the idea that we can build user interfaces really fast, we’re used to the idea of tracking state effectively, and we’re used to the idea that we can write layouts that work great on all of Apple’s platforms.

However, although many people are already comfortable with the way SwiftUI works, we are still quite a long way from agreeing best practices. For example, if you have several different ways of solving a problem, we’re still in the process of figuring out which one makes most sense for a given situation.

So, we’re at this strange position where many people are learning SwiftUI and writing lots of it, but the apps we build are growing in size and complexity and as a result are starting to suffer from very similar problems that we faced in UIKit in the early years.

Previously it was common to tackle the long-standing UIKit curse of Massive View Controllers, but in SwiftUI we don’t have view controllers at all – in fact, many would say that the lack of view controllers is the best feature of SwiftUI!

However, we do make a lot of view structs where we cram a lot of functionality. This created a wholly new problem: Every View Is Large, which helpfully has the acronym EVIL so it already tells you a lot about what I think of this situation.

In practice, EVIL comes in many forms, including:

  • Lawful EVIL: You’re doing your best, but your views still get large.
  • Neutral EVIL: You’d like to do your best, but your team doesn’t have any enough time or resources so your views inevitably get large.
  • Chaotic EVIL: views are dumping grounds like AppDelegate of old

Of course, as we all know the only thing necessary for the triumph of EVIL is for good programmers to do nothing. So, in this article I’m going to present five steps anyone can take to improve their SwiftUI views by making them smaller, simpler, easier to understand, and easier to reuse. These steps are small enough that you can implement them easily no matter what kind of EVIL already exists in your project.

I’ve already created a simple sandbox project for us to work with, called Trekr. You should have downloaded this already, but if not please click here to download it now.

You’ll see there is a Start and End folder. The Start folder contains the initial code that you should open now, but if you’re ever unsure you can open the End folder to see the end result of our work.

If you run the app now you’ll see it’s not a very complicated app, but it’s enough to demonstrate what we’re going to be working on here.

Step one: creating properties

The first way you can simplify your views is to carve off independent parts into properties, or sometimes methods. For example, in ListingView.swift we have a toolbar, and it has a button right there in our body property.

This is important functionality for our app, but honestly it doesn’t need to be there. I mean specifically, it doesn’t need to be there – right in our body property. If you were writing this in UIKit, this kind of code is equivalent to setting the rightBarButtonItems array at the same time as you create your buttons, in one massive line of code, rather than creating the buttons individually then creating an array of them to pass in.

So instead we can just take that whole code out into its own property inside ListingView:

var favoritesButton: some View {
    // paste the button code here
}

And now our toolbar becomes this:

.toolbar {
    favoritesButton
}

This approach works best for supplementary views like toolbar items, tab items, sheets, and alerts, allowing your view’s body to focus on the part that really matters.

These computed properties are completely free in Swift. They are effectively just function calls, and the Swift optimizer can do great things with them. Sure, we could use a method instead, but properties align better with SwiftUI’s body property, and we aren’t doing computationally expensive work here.

Computed properties also work great even when modifying your view’s state, because Swift will recompute them as needed. For example, in the same file we have this big, messy ForEach in our List:

ForEach(showingFavorites ? dataController.locations.filter(dataController.isFavorite) : dataController.locations) { location in

That uses two different pieces of data depending on what state the app is in, so we should take this out into a new computed property like this:

var items: [Location] {
    if showingFavorites {
        return dataController.locations.filter(dataController.isFavorite)
    } else {
        return dataController.locations
    }
}

Notice how I’ve been able to convert the ternary operator into a regular if condition – it’s easier to read when laid out neatly, I think.

The result is that our ForEach becomes much simpler, because we can use the computed property:

ForEach(items) { location in

Again, this takes things out of the body that don’t need to be there – we run our calculations elsewhere, so by the time body runs everything is all set.

That means being on the look out for conditions and deciding whether that condition can be resolved outside of body. For example, if you look in LocationView.swift you’ll see the image header has a large heart image being shown for favorite locations:

if dataController.isFavorite(location) {
    Image(systemName: "heart.fill")
        .foregroundColor(.white)
        .padding()
        .background(Color.red)
        .clipShape(Circle())
        .overlay(
            Circle()
                .strokeBorder(Color.white, lineWidth: 2)
        )
        .offset(x: -10, y: -10)
}

Just like the toolbar button, this view layout matters so we don’t want to get rid of it. But it’s also mixing up layout and logic – there’s a whole bunch of code in there that only applies sometimes.

So, we could take out that whole heart image condition into its own property, like this:

var favoriteImage: some View {
    if dataController.isFavorite(location) {
        Image(systemName: "heart.fill")
            .foregroundColor(.white)
            .padding()
            .background(Color.red)
            .clipShape(Circle())
            .overlay(
                Circle()
                    .strokeBorder(Color.white, lineWidth: 2)
            )
            .offset(x: -10, y: -10)
    }
}

But that won’t work: Swift won’t let us put conditions into simple properties like this.

One way to fix this is to enable SwiftUI’s view builders, like this:

@ViewBuilder var favoriteImage: some View {

But honestly I consider that a code smell – you’re explicitly saying you need logic to build complex views here.

So I want to move on to the second simple way you can make your views simpler, smaller, and easier to understand: break them up!

Step two: breaking up your views

Of all the ways you can simplify your SwiftUI views, this is the one that I think is most effective: creating smaller SwiftUI views that have individual pieces of behavior, then composing them together into larger views.

In our sample project, that would mean taking out the location image and heart image into a new view called LocationHeader, then using that inside our existing LocationView.

Go ahead and undo the @ViewBuilder computed property we just tried, so you get back to the original LocationView.swift file.

Next, create a new SwiftUI view called LocationHeader. You need to copy the hero picture and heart images from LocationView into the body of LocationHeader, like this:

struct LocationHeader: View {   
    var body: some View {
        Image(location.heroPicture)
            .resizable()
            .scaledToFit()

        if dataController.isFavorite(location) {
            Image(systemName: "heart.fill")
                .foregroundColor(.white)
                .padding()
                .background(Color.red)
                .clipShape(Circle())
                .overlay(
                    Circle()
                        .strokeBorder(Color.white, lineWidth: 2)
                )
                .offset(x: -10, y: -10)
        }
    }
}

That code hasn’t changed – I just moved it from LocationView to LocationHeader. We could even move the ZStack if you wanted, depending on whether you might want alternative layouts elsewhere in your app.

You’ll need to adjust the LocationHeader_Previews struct at the same time, to pass in an example location. I already provided one of these for you, so you can change the preview struct like so:

struct LocationHeader_Previews: PreviewProvider {
    static var previews: some View {
        LocationHeader(location: Location.example)
    }
}

Now, the code we copied across relies on dataController and location properties in the LocationHeader struct, so please add these now:

@EnvironmentObject var dataController: DataController
let location: Location

That’s the new view complete, so now back in LocationView our ZStack is really simple:

ZStack(alignment: .bottomTrailing) {
    LocationHeader(location: location)
}

This approach is particularly powerful when used with list rows, either in a List directly or in a ForEach. For example, in ListingView nearly all the view code is actually describing one row, and this is a prime candidate for making into its own view just like how we used to subclass UITableViewCell when using UIKit.

So, start by cutting the whole NavigationLink to the clipboard, including everything inside it. Now make the new ListingRow SwiftUI view, and paste your clipboard into the body property, like this:

struct ListingRow: View {   
    var body: some View {
        NavigationLink(destination: LocationView(location: location)) {
            Image(location.country)
                .resizable()
                .frame(width: 80, height: 40)
                .cornerRadius(5)
                .overlay(
                    RoundedRectangle(cornerRadius: 5)
                        .strokeBorder(Color.black,   lineWidth: 1)
                )

            VStack(alignment: .leading) {
                Text(location.name)
                    .font(.headline)

                Text(location.country)
                    .foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 5)
    }
}

To make that work we can add one simple property to the new view:

let location: Location

And that’s the view complete – it’s mostly just moving code from one place to another, but it really helps isolate the functionality.

With that change our ForEach in ListingView becomes trivial:

ForEach(items) { location in
    ListingRow(location: location)
}

Even better, we can go a step further because two marvelous Swift features combine here.

First, because all our SwiftUI views are automatically structs, we get a memberwise initializer.

Second, the second parameter to the initializer of ForEach takes any kind of function. We usually pass it a trailing closure of whatever code we want inside the loop, but we can also send in any kind of other function.

So, on the one hand Swift makes a memberwise initializer for our list row – a function that accepts a location and returns a new view – and on the other hand ForEach wants a function that accepts an item from its array (which in this case is a location!) and returns a view to use in the loop.

Sound familiar?

Yep, we can boil our ForEach right down to this:

ForEach(items, content: ListingRow.init)

Much better!

Step three: getting action code out of your view body

Getting action code out of your view body is the flip side of getting your logic out of your views, and it’s just as important.

For example, in LocationView we have two buttons after all the text, both of which put their action closure right into the body property.

Just look at the Remove Favorite button:

Button(dataController.isFavorite(location) ? "Remove Favorite" : "Add Favorite") {
    dataController.toggleFavorite(location)
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.clipShape(Capsule())

That has layout, logic, and action all mixed up in one place, which is a nightmare. At the very least, we should be getting the action code out of our body even though right now it’s just a single line of code.

So, cut the dataController.toggleFavorite() call to the clipboard, then put it into a method like this

func toggleFavorite() {
    dataController.toggleFavorite(location)
}

And now the button code becomes simpler:

Button(dataController.isFavorite(location) ? "Remove Favorite" : "Add Favorite", action: toggleFavorite)

You might think this change is worse, because our code ends up being longer. And it’s particularly odd given we only moved one one of code!

But now it’s a separate method, we can write a unit test for it if we want – we can instantiate the view struct, call the method directly, then verify it worked correctly, rather than writing a UI test. And let’s face it, most of the time you don’t want to write a UI test unless you hate yourself.

We can do exactly the same with the “Show on Map” button:

func showOnMap() {
    showingMap = true
}

Then rewrite the button:

Button("Show on Map", action: showOnMap)

Again, we’re only moving one line of code out, but it’s surprisingly common to see actual complex work in these action functions so getting them out does wonders for making your code easier to understand.

What I’ve found time and time again is that I’m either working on my layout, or thinking about a particular action; I’m rarely doing both. So, this really helps segment my code in a way my brain understands. Plus it makes the jump bar useful – it doesn’t work with inline closures.

You can even take this approach inside computed properties – you can break things up further and further. So, in ListingView we have the favoritesButton we made earlier, and we could update it to this:

func toggleFavorites() {
    withAnimation {
        showingFavorites.toggle()
    }
}

var favoritesButton: some View {
    Button(action: toggleFavorites) {
        if showingFavorites {
            Text("Show all locations")
        } else {
            Text("Show only favorites")
        }
    }
}

Again, this means we have the ability to trigger functionality in our view directly, rather than by calling a specific UI test; it’s much nicer. Plus we’re breaking up layout and functionality, which is always nice.

Step four: creating View extensions

For our fourth way to simplify views I want to head back to LocationView.swift, because here you can see we have three titles in our view: one for the country, one for a “did you know?” fact, and one for travel advisories.

These all have a particular style, combining four different modifiers. This is unnecessarily long, and also flaky because if we change one location, we need to remember to change it elsewhere.

A better idea is to wrap up your custom functionality as an extension to one of SwiftUI’s views, like this:

extension Text {
    func headerStyle() -> some View {
        self
            .font(.title2)
            .foregroundColor(.secondary)
            .fontWeight(.black)
            .textCase(.uppercase)
    }
}

With that in place you can now replace all those modifiers with a single one. For example:

Text("Country: \(location.country)")
    .headerStyle()

I find view extensions particularly useful for making platform adjustments. For example, if a view needed extra padding on iOS but not macOS, I might have a helper method like this one:

extension View {
    func iOS<Content: View>(_ modifier: (Self) -> Content) -> some View {
        #if os(iOS)
        return modifier(self)
        #else
        return self
        #endif
    }
}

And now you can conditionally apply modifiers just for iOS, like this:

.iOS { $0.padding(10) }

You can of course create other variants for macOS, watchOS, or whatever platform you’re targeting.

Step five: creating styles

The fifth and final step to making better views is to use styles, which are a separate beast to view extensions.

In LocationView, our two buttons both share a specific, custom style. We could of course take that style into another view extension, like this:

extension Button {
    func primaryStyle() -> some View {
        self
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .clipShape(Capsule())
    }
}

And honestly that works – you could use that if you wanted. It would turn your code into this:

Button(dataController.isFavorite(location) ? "Remove Favorite" : "Add Favorite", action: toggleFavorite)
    .primaryStyle()

However, SwiftUI has a better solution: the ButtonStyle protocol. This will hand us the button label as a configuration view and we can position or modify it however we want.

To use it, first change our extension to this:

struct PrimaryButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .background(configuration.isPressed ? Color.blue.opacity(0.5) : Color.blue)
            .foregroundColor(.white)
            .clipShape(Capsule())
    }
}

And now change the primaryStyle() calls to this:

.buttonStyle(PrimaryButtonStyle())

In this instance our result is pretty much identical, but now that we’re using a custom button style we get extra control that wouldn’t be possible with a simple view extension. For example, we might want to make our button look different while it’s being actively pressed down, which is available to use through configuration.isPressed.

So, we might make our button slightly transparent when pressed down, by changing its background() modifier to this:

.background(configuration.isPressed ? Color.blue.opacity(0.5) : Color.blue)

Not only has our view code become smaller, while also making a button style completely reusable elsewhere in our project, but we’ve also now gained extra functionality that was previously impossible.

Wrap up

That wraps up our discussion of how to banish EVIL from your project, replacing it with beautiful SwiftUI code you can be proud of.

To summarize, we’ve looked at five ways to create simpler SwiftUI views:

  1. Creating properties for your supplementary views
  2. Breaking larger views into smaller ones
  3. Getting action code out of your view body
  4. Creating View extensions for styling
  5. Using ButtonStyle and other style protocols

Hopefully you’ve picked up some good ideas here, but I’d love to hear your suggestions – what do you do to keep your SwiftUI views small? Tweet me @twostraws and let me know!

Click here to save 50% on all my books and bundles!

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

About the author

Paul Hudson is the creator of Hacking with Swift, the most comprehensive series of Swift books in the world. He's also the editor of Swift Developer News, the maintainer of the Swift Knowledge Base, and a speaker at Swift events around the world. If you're curious you can learn more here.

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.