Chloe’s Opinionated and Biased Law of SwiftUI

How to establish law and convention in the Wild West of SwiftUI

Chloe Houlihan
The Tech Collective

--

Chloe enforcing the law, circa 1885 - Image by Ilya Sedykh

Having used SwiftUI since 2019 and as my primary language in a production app since 2020, I’ve learned a thing or two and I’ve also developed strong opinions about what I think is right, and, of course, everyone else is wrong!!

This is all of these things merged into My Word of Law. It’s up to you to pick which bits are well-reasoned and a good idea, and which bits are just me being a grumpy pedant.

In all seriousness though, this is just one engineer’s thoughts. They have been well received and adopted by all my teams so far, and so I thought they were worth sharing, especially for teams new to SwiftUI who are just looking for a jumping-off point for convention.

Basic Rules of Thumb

These tips are aimed at avoiding weird SwiftUI things occurring, making your code more readable, maintainable, debuggable and reducing smells.

Sizes/Dimensions

You can now use Double instead of CGFloat . Putting all your Doubles in their own Struct (called Sizesor Dimensions or similar) has multiple benefits:

public struct MyView: View {
private let sizes: Sizes

public init(sizes: Sizes = .init()) {
self.sizes = sizes
}

public var body: some View {
VStack(spacing: .zero) {
MySubView()
.padding(.horizonal, sizes.smallPadding)
.padding(.top, sizes.padding)
MyButton(width: sizes.buttonWidth)
.padding(.horizonal, sizes.smallPadding)
.padding(.vertical, sizes.padding)
MyImage()
.resizeable()
.fixedSize()
.frame(width: sizes.imageSize)
}
}
}

extension MyView {
public struct Sizes {
// MARK: Common sizes
let padding: Double
let smallPadding: Double

// MARK: Button sizes
let buttonWidth: Double

// MARK: Image sizes
let imageSize: Double

public init(padding: Double = 16,
smallPadding: Double = 8,
buttonWidth: Double = 24,
imageSize: Double = 50) {
self.padding = padding
self.smallPadding = smallPadding
self.buttonWidth = buttonWidth
self.imageSize = imageSize
}
}
}

Pros:

  • Dimensions are injectable making the view more reusable and customisable
  • Reduces repetition of values, meaning fewer mistakes if a value changes
  • All Doubles are in one place which reduces visual smells across the View. This is because the syntax highlighting will be of constants, not of raw values (no raw values in the View).
  • Values are easily adjustable in one place making maintenance quicker and easier, especially for engineers unfamiliar with this particular view
  • Sizes can be reused and shared/injected across multiple views if applicable
  • You’d be a big dumb-dumb not to

Cons:

  • More code (but less repeated code)
  • Dimension and component names need to be very clear to avoid engineers getting confused about which values control which view dimensions (in a way, this is a good thing as it forces engineers to make their code clear and readable, maybe even adding docs)

Padding- “Don’t Do”s

Text().padding(EdgeInsets(
top: 0,
leading: 8,
bottom: 0,
trailing: 8
))
Text()
.padding(.top, 8)
.padding(.bottom, 8)
Text().padding()

Padding- “Do Do”s 💩

Text().padding(.horizontal, 8)
Text().padding(.vertical, 8)
  • Use .all, .horizontal and .vertical
Text().padding(.all, 8)
  • You should ALWAYS specify dimensions: Which edges to pad, and by how much
  • Otherwise, don’t use padding. Just… just don’t…

Spacing- “Don’t Do”s

VStack {}
HStack {}
VStack(spacing: 0)

Spacing- “Do Do”s

VStack(spacing: .zero) {}
HStack(spacing: .zero) {}
  • Using the constant .zero instead of the variable 0 reduces smells by allowing our eyes to scan past it without seeing a stray Double highlighted in syntax (this is because we’re going to keep our dimensions tucked away in their own Struct), instead highlighting it as a constant
  • If I see little blue numbers dotted around my Views, I can smell ’em all like yesterday’s fish

Padding Vs Spacing

For more, read: SwiftUI Padding Vs Spacing

Don’t do:

VStack(spacing: 8) {
Text()
Text()
Button().padding(.top, 4)
}
VStack(spacing: 8) {
Text("Title")
Text("Description")
}
VStack(spacing: 8) {
MyThing(models[0])
MyThing(model[1])
MyThing(model[2])
}

Do instead:

(Note: These doubles should be in their own struct, and copy in the logic layer)

VStack(spacing: .zero) {
Text()
Text().padding(.top, 8)
Button().padding(.top, 12)
}
VStack(spacing: .zero) {
Text("Title").padding(.bottom, 8)
Text("Description")
}
VStack(spacing: 8) {
ForEach(models) { model in
MyThing(model)
}
}

Frame- Don’t Do

Image()
.resizable
.frame(width: 10, height: 10)
  • If the aspect ratio of the asset changes, this will stretch the image

Frame- Do Do

(Note: These doubles should be in their own struct, and copy in the logic layer)

Image()
.resizable()
.fixedSize() // Or .scaleToFit()
.frame(width: 10) // Or height: 10
  • Use either width or height (think about which one you want to stay the same if the image became taller/wider)
  • Use .scaleToFit (for SF icons) or .fixedSize (for assets) to keep the aspect ratio

Animations- Don’t Do

(Note: Doubles should be in their own struct, and copy/logic in the logic layer)

VStack(spacing: .zero) {
if x {
MySubView()
}
Button {
withAnimation {
x = 5
}
}, label: {
Text("My button")
}
}
  • Don’t use withAnimation { } because it’s ugly as sin.

Animations- Do Do

VStack(spacing: .zero) {
if x == 5 {
MySubView().transition(.slide) // Often not needed
}
Button(action: buttonTapped) {
Text("My button")
}
}.animation(.default, value: x)


// In the ViewModel (VM), where `x` is a VM field:
func buttonTapped() {
x = 5
}
  • Use .animation on the container view- All the cool kids are doing it
  • Use .transition on the specific components to dictate how you want each to animate
  • The view/container the modifier is attached to, and everything inside it, will automatically animate any changes caused when the value updates (e.g. here MySubView will slide out)
  • In MVVM, @Published x, buttonTapped and x == 5are in your view model (logic layer)

Containers: Stacks, Groups and ViewBuilders

Don’t use containers needlessly

public var body: some View {
VStack(spacing: .zero) {
topSection
middleSection
bottomSection
}
}

private var topSection: some View {
VStack(spacing: .zero) {...}
}

private var middleSection: some View {
VStack(spacing: .zero) {...}
}

private var bottomSection: some View {
VStack(spacing: .zero) {...}
}
  • Don’t have 3 identical VStacks (same spacing, alignment, padding, etc) inside another identical VStack just for the sake of separating out the body for readability

Do use @ViewBuilder

public var body: some View {
VStack(spacing: .zero) {
topSection
middleSection
bottomSection
}
}

@ViewBuilder private var topSection: some View {
...
}

@ViewBuilder private var middleSection: some View {
...
}

@ViewBuilder private var bottomSection: some View {
...
}
  • Use @ViewBuilder to take the components and add them to the upper container- in this case adding the components of the different sections to the body VStack
  • Don’t use Group instead of @ViewBuilder: This will reduce the repeated containers but doesn’t decrease the nesting like @ViewBuilder does. We’re not sparrows, stop nesting.

Don’t repeat modifiers

if viewModel.something {
thing1.someModifer()
} else {
thing2.someModifer()
}
  • Don’t apply the same modifier to multiple components, common when components are conditional (in an if/else, or switch)

E.g.

  • .animation(.default, viewModel.something)
  • .padding(.all, sizes.padding)

Do use Group

Group {
if viewModel.something {
thing1
} else {
thing2
}
}.someModifer()
  • Subviews are sheep- they’ll follow anything the group is doing. If the teacher asks them “If the group jumped off a cliff, would you do it?” their answer is “hell, yeah”.
  • Use Group to apply modifiers to multiple components and reduce repetition

The Triangle of Death

If you nest too many views inside each other, resulting in enough indentation to create a “triangle-like” effect, you may get a confusing error at the body level. This is essentially the compiler saying “This is a total mess, I can’t even help you anymore. Something’s wrong, I guess, but ̶G̶o̶d̶ Jobs knows what!”.

public var body: some View {
ZStack {
Color.white
VStack(spacing: .zero {
Text("Title")
.font(.title)
.foregroundColor(.green)
.padding(.vertical, 8)
Text("Subtitle")
.font(.subtitle)
.foregroundColor(.blue)
.padding(.bottom, 8)
VStack(spacing: .zero) {
ForEach(items) { item in
ScrollView {
HStack(spacing: 4) {
ForEach(images) { image in
Image(image)
.resizable()
.fixedSize()
.frame(width: 50)
VStack(spacing: .zero) {
Text("Image name")
.font(.title2)
.foregroundColor(.blue)
.padding(.bottom, 2)
Text("Image description")
.font(.subtitle2)
.foregroundColor(.green)
}
}
}
}
}
}
}
Text("^ Scroll through these image reels")
.font(.caption)
.foregroundColor(.red)
.padding(.top, 16)
}.padding(.horizontal, 16)
}
}

Not pretty, is it? Let’s try using private variables to break this ToD (Triangle of Death) down.

public var body: some View {
ZStack {
Color.white
content
}
}

private var content: some View {
VStack(spacing: .zero {
header
itemsList
instruction
}.padding(.horizontal, 16)
}

@ViewBuilder private var header: some View {
Text("Title")
.font(.title)
.foregroundColor(.green)
.padding(.vertical, 8)
Text("Subtitle")
.font(.subtitle)
.foregroundColor(.blue)
.padding(.bottom, 8)
}

private var itemsList: some View {
VStack(spacing: .zero) {
ForEach(items) { item in
imageReel(item)
}
}
}

private func imageReel(_ item: Item) -> some View {
ScrollView {
HStack(spacing: 4) {
ForEach(images) { image in
imageProfile(image)
}
}
}
}

@ViewBuilder private func imageProfile(_ image: String) -> some View {
Image(image)
.resizable()
.fixedSize()
.frame(width: 50)
VStack(spacing: .zero) {
Text("Image name")
.font(.title2)
.foregroundColor(.blue)
.padding(.bottom, 2)
Text("Image description")
.font(.subtitle2)
.foregroundColor(.green)
}
}

private var instruction: some View {
Text("^ Scroll through these image reels")
.font(.caption)
.foregroundColor(.red)
.padding(.top, 16)
}

Pros

  • Reduced horizontal space
  • Reduced nesting (take that, sparrows)
  • Easier to read
  • Quicker to find components in code
  • Easier to rearrange the view
  • Top-level body and subviews are much quicker and easier to read/visualise (seeing a list of components in order instead of their full code)
  • No body-level compiler error: Debugging easier, inline errors/warnings enabled
  • Chloe is happy

Cons

  • More vertical space
  • More code

Conclusion

I can already hear my CompSci first-years (I used to mark Java lab work to make my rum money) saying “But it runs fine without this stuff”. To which my response was normally “Shut up and do it, n00b!”.

Of course, with maturity comes patience (or complacency, you decide). And yes, most of this won’t change how the app compiles, performs or runs. But let me explain why we should do it anyway.

Conventions will make your PR reviewers happy. And if your whole team conforms to this, you’ll all be better able to sniff out those smells in SwiftUI views, drawing your nose straight into those potential bugs (e.g. unintentional combined padding and spacing) like a nerd to overpriced D&D merch. Essentially, they make the code more readable, therefore, more maintainable and easier to debug.

Also, it’s pretty.

--

--

Chloe Houlihan
The Tech Collective

Senior Software Engineer, whiskey lover (drink and my dog), human (just about). @ xDesign