Learn to make your SwiftUI views smaller, simpler, and more reusable.
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.
Still here? Okay, let’s get to it…
SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.
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:
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.
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!
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!
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.
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.
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.
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:
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!
SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.
Link copied to your pasteboard.