Cleaner Views: Clean Code in SwiftUI
Motivation
With the increasing popularity of SwiftUI since its release in 2019 and adoption by large projects, more and more UI has been created in SwiftUI View
, but some poorly. The main advantage of using a declarative framework is using simpler and minimal code to define a component. However, misusing it can lead to a recipe for disaster.
We in the iOS team of this organization follow some guidelines inspired by Uncle Bob's Clean Code book, and they'll be put into practice as an example in this article.
In the iOS projects we take part, we strongly advocate the usage of SwiftUI as the main framework for UI creation, as seen in my fellow colleague's article.
Here at ProFUSION we always value code quality and reviews. For those things to be possible, code must be easily maintainable and readable.
Well, shall we get started? Let's dive into some SwiftUI code, as seen in many real-world projects
PS: This code will pass just fine in a default SwiftLint configuration, but in reality it's a nightmare to maintain.
The Code
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading) {
HStack {
Image(systemName: "person")
.foregroundColor(.black)
VStack(alignment: .leading) {
Text("Name Surname")
Text("Person A")
.foregroundColor(.gray)
.font(.footnote)
}
.padding(.leading, 10)
}
Divider()
HStack {
Image(systemName: "person.circle")
.foregroundColor(.blue)
Text("This is item number one")
.foregroundColor(.blue)
}
.padding(.vertical, 10)
Divider()
HStack {
Image(systemName: "person")
.foregroundColor(.red)
Text("This is item number two")
.foregroundColor(.red)
}
.padding(.top, 10)
Divider()
}
.padding()
}
}
}
There are a few key action takeaways that can be gathered when reading the code above.
From a Clean Code perspective:
1.It should be divided into smaller functions/variables that create an individual View
. A huge body
is always a huge problem
2.It includes repetitive blocks of code
3.It uses magic numbers. Those should be constants and have names describing each usage/meaning
From SwiftUI perspective:
4.It uses implicit values for .padding
and spacing
to space elements. It could cause unexpected behavior due to defaulting to the framework "magic values"
5.It uses .padding
to define space between elements in a stack instead of the Stack's parameter spacing
6.It applies .foregroundColor
individually to each View, instead of setting it for the whole Stack
So let's tackle those issues one by one
Refactoring
1. It should be divided into smaller functions/variables that create an individual View
This is a major issue in complex Views, where the code gets more deeply nested/indented. That's the case in SwiftUI
when we need to insert a component inside of another component to declare a child component and so on.
So let's extract the nested code or, as Uncle Bob says, "Extract till you Drop".
struct ContentView: View {
var body: some View {
ScrollView {
content
}
}
@ViewBuilder private var content: some View {
VStack(alignment: .leading) {
personInfo
renderListItemOne()
renderListItemTwo()
}
.padding()
}
@ViewBuilder private var personInfo: some View {
HStack {
Image(systemName: "person")
.foregroundColor(.black)
VStack(alignment: .leading) {
Text("Name Surname")
Text("Person A")
.foregroundColor(.gray)
.font(.footnote)
}
.padding(.leading, 10)
}
Divider()
}
@ViewBuilder private func renderListItemOne() -> some View {
HStack {
Image(systemName: "person.circle")
.foregroundColor(.blue)
Text("This is item number one")
.foregroundColor(.blue)
}
.padding(.vertical, 10)
Divider()
}
@ViewBuilder private func renderListItemTwo() -> some View {
HStack {
Image(systemName: "person")
.foregroundColor(.red)
Text("This is item number two")
.foregroundColor(.red)
}
.padding(.top, 10)
Divider()
}
}
This way we can use either a variable or a function to define what each block of code should produce, giving meaning to a code and making it easily readable by anyone. Remember that functions need to start with a verb because they do something, as default I use renderFoo/Bar
.
It's good practice to declare functions/variables that create Views
as a @ViewBuilder
, as one of its properties is to accept a conditional View
. I've seen conditional Views wrapped with a Group
to workaround that. Needless to say that's wrong. Example:
// Compiler error
private func renderOptionalLabel(text: String?) -> some View {
if let text {
Text(text)
}
}
// Will not generate compiler error, but why use Group?
private func renderOptionalLabel(text: String?) -> some View {
Group {
if let text {
Text(text)
}
}
}
// Way better
@ViewBuilder private func renderOptionalLabel(text: String?) -> some View {
if let text {
Text(text)
}
}
2. It includes repetitive blocks of code
As we can see, both functions basically produce the same view:
renderListItemOne
uses color, label, and an image Foo.
renderListItemTwo
uses color, label, and an image Bar.
Can you see it? There are a couple of ways to solve this. The straightforward approach is to make the functions
accept arguments instead of hardcoding the values in them, like this:
...
@ViewBuilder private var content: some View {
VStack(alignment: .leading) {
personInfo
renderListItem(imageName: "person.circle", description: "This is item number one", color: .blue)
renderListItem(imageName: "person", description: "This is item number two", color: .red)
}
}
...
@ViewBuilder private func renderListItem(
imageName: String,
description: String,
color: Color
) -> some View {
HStack {
Image(systemName: imageName)
.foregroundColor(color)
Text(description)
.foregroundColor(color)
}
.padding(.vertical, 10)
Divider()
)
We can also use my favorite tool, Enums
, to achieve a more strict set of possible list items, but flexible to new cases:
...
@ViewBuilder private var content: some View {
VStack(alignment: .leading) {
personInfo
renderListItem(type: .one)
renderListItem(type: .two)
}
.padding()
}
...
@ViewBuilder private func renderListItem(type: ListItemType) -> some View {
HStack {
Image(systemName: type.imageName)
.foregroundColor(type.foregroundColor)
Text(type.description)
.foregroundColor(type.foregroundColor)
}
.padding(.vertical, 10)
Divider()
}
}
extension ContentView {
enum ListItemType {
case one
case two
var imageName: String {
switch self {
case .one:
return "person.circle"
case .two:
return "person"
}
}
var description: String {
switch self {
case .one:
return "This is item number one"
case .two:
return "This is item number two"
}
}
var foregroundColor: Color {
switch self {
case .one:
return .blue
case .two:
return .red
}
}
}
}
By creating a ListItemType
enum we can easily associate values to each case by creating computed variables.
I'm letting point #3 intentionally to be the last.
SwiftUI related issues
4.It uses implicit values for .padding
and spacing
to space elements. It could cause unexpected behavior due to defaulting to the framework "magic values"
In the .padding
documentation
SwiftUI uses a platform-specific default amount.
That same amount is applied to the spacing
value of an HStack
/VStack
. No value defined means losing control over what those default "magic values" could be. They may change and potentially break the UI in a new iOS update.
For Stacks you can create an extension where if you left the spacing = nil
it would default to .zero
, but in the case of the .padding
modifier, it makes no sense for it to default to .zero
, so there is no workaround for your project to avoid this last one.
Anyway, be warned, you could save some headaches when a new iOS version is launched and all snapshot tests fail (usage of List
was also a culprit...).
5.It uses .padding
to define space between elements in a stack instead of the Stack's parameter spacing
Only use .padding
when the space between elements inside a stack is not constant, when that's the case always specify spacing: .zero
so you can manipulate the distribution of each element individually.
6.It applies .foregroundColor
individually to each View, instead of setting it for the whole Stack
One of the properties of .foregroundColor
is that it applies to its child Views, so we are trading two lines for one instead, I call it a win.
The refactored code
...
@ViewBuilder private var content: some View {
VStack(alignment: .leading, spacing: 8) {
personInfo
renderListItem(type: .one)
renderListItem(type: .two)
}
.padding(16)
}
@ViewBuilder private var personInfo: some View {
HStack(spacing: 10) {
Image(systemName: "person")
.foregroundColor(.black)
VStack(alignment: .leading) {
Text("Name Surname")
Text("Person A")
.foregroundColor(.gray)
.font(.footnote)
}
}
Divider()
}
@ViewBuilder private func renderListItem(type: ListItemType) -> some View {
HStack(spacing: 10) {
Image(systemName: type.imageName)
Text(type.description)
}
.foregroundColor(type.foregroundColor)
Divider()
}
...
Now the code looks so much better and easier to read, but still it's missing one last change that I purposely left to be the last because that's the last change I make in my code.
3.It uses magic numbers. Those should be constants and have names describing each usage/meaning
Let's take the example of the following line
HStack(spacing: 10) {
This number was inserted for the UI to look good, but by using literal numbers you will make a person who looks at the code make some questions:
Is there a design system in the app? If so why is this View not using constant values defined by it?
A distance of 10px between what? Does it only happen here?
For the first question, I always verify if the value exists in the app and follow the design system, that way I can assure the UI is following some design-defined value. The code will be rewritten as:
HStack(spacing: Spacing.medium) {
Where Spacing.medium = 10
. Any change along this line of thought will make the code look cautiously written.
However there are situations where this value doesn't appear in the design system at all, so we will need to specifically use Constant
. That's what I call the namespace
to store all those View
specific values. More on this usage here.
enum Constant {
static let imageLabelSpacing: CGFloat = 10
}
So in the end we have:
struct ContentView: View {
var body: some View {
ScrollView {
content
}
}
@ViewBuilder private var content: some View {
VStack(alignment: .leading, spacing: Constant.listSpacing) {
personInfo
renderListItem(type: .one)
renderListItem(type: .two)
}
.padding(Constant.globalPadding)
}
@ViewBuilder private var personInfo: some View {
HStack(spacing: Constant.imageLabelSpacing) {
Image(systemName: "person")
.foregroundColor(.black)
VStack(alignment: .leading) {
Text("Name Surname")
Text("Person A")
.foregroundColor(.gray)
.font(.footnote)
}
}
Divider()
}
@ViewBuilder private func renderListItem(type: ListItemType) -> some View {
HStack(spacing: Constant.imageLabelSpacing) {
Image(systemName: type.imageName)
Text(type.description)
}
.foregroundColor(type.foregroundColor)
Divider()
}
}
...
extension ContentView {
enum Constant {
static let globalPadding: CGFloat = 16
static let listSpacing: CGFloat = 8
static let imageLabelSpacing: CGFloat = 10
}
}
Are we done? Partially! There are hardcoded strings in the View
that need to be inserted into a string table and properly named after. That may vary on projects, depending on the localization infrastructure. But that's not the scope of this article so, for now, we are done.
The whole code can be seen here with the commits for each step taken from this article.
Conclusion
There's a whole hype around SwiftUI because it's easier and faster, but with great power comes great responsibility. Your responsibility now is to clean your views and see good and tidy code.
This article just tackles the easiest part of an iOS developer, which is developing UI. Your greatest challenge will be simplifying your code through generics, protocols, architecture, etc. So at least the bread-and-butter must be done with the utmost excellence and attention.