GO FURTHER, FASTER: Try the Swift Career Accelerator today! >>

8 Common SwiftUI Mistakes - and how to fix them

Write less code and get more done

Paul Hudson       @twostraws

SwiftUI is a big and complex framework, and although it’s great fun to work with there’s also a lot of scope for making mistakes. In this article I’m going to walk through eight common mistakes SwiftUI learners make, and how to fix them.

Some of these mistakes are simple misunderstandings, and with SwiftUI being so big these are easy to make. Others are about getting a deeper understanding of how SwiftUI works, and still others are more a sign of legacy thinking – sometimes you spend a lot of time writing views and modifiers and don’t take the time to simplify the end result.

Anyway, I’m not going to keep you in suspense about what these eight mistakes are, so here’s a brief summary before we dive into them:

  1. Adding views and modifiers where they aren’t needed
  2. Using @ObservedObject when they mean @StateObject
  3. Putting modifiers in the wrong order
  4. Attaching property observers to property wrappers
  5. Stroking shapes when they mean to stroke the border
  6. Using alerts and sheets with optionals
  7. Trying to get “behind” their SwiftUI view
  8. Creating dynamic views using invalid ranges

If you prefer to watch a video instead, I've got you covered!

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

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure and A/B test your entire paywall UI without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

1. Adding views and modifiers where they aren’t needed

Let’s start with one of the most common, which is writing more SwiftUI code than you actually need. This is common partly because it’s often we write a lot of code as we’re figuring out a problem, but it’s easy to forget to clean up that code afterwards. It’s also sometimes a matter of slipping back into old habits, particular if you’ve come from UIKit or other user interface frameworks.

As a starting example, how might you fill the screen with a red rectangle? You might write this:

Rectangle()
    .fill(Color.red)

And honestly that works great – it gets the exact result you want. But half that code isn’t needed, because you can instead just say this:

Color.red

This is because all SwiftUI’s colors and shapes automatically conform to the View protocol, so you can use them directly as views.

You’ll also commonly see this with clip shapes, because it’s really natural to apply clipShape() with whatever shape you want. For example, we could make our red rectangle have rounded corners like this:

Color.red
    .clipShape(RoundedRectangle(cornerRadius: 50))

But it just isn’t required – you can dramatically simplify your code by using the cornerRadius() modifier, like this:

Color.red
    .cornerRadius(50)

Removing this redundant code takes time, because you need to shift your mind set a little. It’s also harder to do when you’re just learning SwiftUI, so don’t feel bad if you’re using the longer versions as you learn.

2. Using @ObservedObject when they mean @StateObject

SwiftUI provides a number of property wrappers to help us build data-responsive user interfaces, and three of the most important are @State, @StateObject, and @ObservedObject. Knowing when to use each of these really matters, and getting it wrong will cause all sorts of problems in your code.

The first one is straightforward: @State should be used when you have a value-type property that is owned by the current view. So, integers, strings, arrays of stuff, and more are all great candidates for @State.

But the latter two cause some confusion, and it’s common to see code like this:

class DataModel: ObservableObject {
    @Published var username = "@twostraws"
}

struct ContentView: View {
    @ObservedObject var model = DataModel()

    var body: some View {
        Text(model.username)
    }
}

This is categorically wrong and is likely to cause problems in your apps.

As I said, @State means a value-type property owned by the current view, and that “owned” part is important. You see, the code above should use @StateObject because the “state” part means “this is owned by the current view.”

So, it should be this:

@StateObject model = DataModel()

When you use @ObservedObject to create a new instance of an object, your view does not own the object, which means it could be destroyed at any time. Cunningly, this will only fail sometimes, so you might think your code is perfectly fine, only to have it fail at some random point later on.

The important thing to keep in mind is that @State and @StateObject means “this view owns the data,” whereas other property wrappers such as @ObservedObject and @EnvironmentObject do not.

3. Putting modifiers in the wrong order

Modifier order matters hugely in SwiftUI, and getting it wrong will cause your layouts to look wrong, but also to behave poorly too.

The canonical example of this problem is using padding and backgrounds, like this:

Text("Hello, World!")
    .font(.largeTitle)
    .background(Color.green)
    .padding()        

Because we’re applying the padding after the background color, the color will only be applied directly around the text, and not around the padded text. If you want both to be green, you should do this:

Text("Hello, World!")
    .font(.largeTitle)
    .padding()    
    .background(Color.green)

This becomes particularly interesting when you try to adjust the position of views.

For example, the offset() modifier changes the location a view is rendered, but doesn’t change the actual dimensions of the view. This means modifiers applied after the offset act as if the offset never happen.

Try this out:

Text("Hello, World!")
    .font(.largeTitle)
    .offset(x: 15, y: 15)
    .background(Color.green)

You’ll see that offsets the text, but not the background color. Now try swapping offset() and background():

Text("Hello, World!")
    .font(.largeTitle)
    .background(Color.green)
    .offset(x: 15, y: 15)

Now you’ll see the text and background get moved.

Alternatively, the position() modifier changes the location a view is rendered in its parent, but can only do so by applying a flexibly sized frame around it first.

Try this out:

Text("Hello, World!")
    .font(.largeTitle)
    .background(Color.green)
    .position(x: 150, y: 150)

You’ll see the background color fits neatly around the text, and the whole thing is positioned near the top-left corner. Now try swapping background() and position():

Text("Hello, World!")
    .font(.largeTitle)
    .position(x: 150, y: 150)
    .background(Color.green)

This time you’ll see the whole screen goes green. Again, using position() requires SwiftUI to place a flexibly sized frame around the text view, which will automatically take up all available space. We then color that green, which is why the whole screen appears green.

All this happens because most modifiers you apply create new views – applying a position or a background color wraps up whatever you had in a new view with that modifier applied. This has the important benefit that we can apply modifiers multiple timers to create new effects, such as adding multiple paddings and backgrounds:

Text("Hello, World!")
    .font(.largeTitle)
    .padding()
    .background(Color.green)
    .padding()
    .background(Color.blue)

Or applying multiple shadows to create a super dense effect:

Text("Hello, World!")
    .font(.largeTitle)
    .foregroundColor(.white)
    .shadow(color: .black, radius: 10)
    .shadow(color: .black, radius: 10)
    .shadow(color: .black, radius: 10)

4. Attaching property observers to property wrappers

There are some situations where you’re able to attach property observers such as didSet to property wrappers, but often this just doesn’t work as you would expect.

For example, if you were using a slider and wanted to take some action when the slider value changed, you might try to write this:

struct ContentView: View {
    @State private var rating = 0.0 {
        didSet {
            print("Rating changed to \(rating)")
        }
    }

    var body: some View {
        Slider(value: $rating)
    }
}

However, that didSet property observer is never called, because the value is being changed directly by the binding rather than creating a new value each time.

The SwiftUI native approach to this is to use an onChange() modifier, like this:

struct ContentView: View {
    @State private var rating = 0.0

    var body: some View {
        Slider(value: $rating)
            .onChange(of: rating) { value in
                print("Rating changed to \(value)")
            }
    }
}

However, I prefer a slightly different solution: I use an extension on Binding itself that gets and sets the wrapped value as before, but also calls a handler function with the new value:

extension Binding {
    func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
        Binding(
            get: { self.wrappedValue },
            set: { newValue in
                self.wrappedValue = newValue
                handler(newValue)
            }
        )
    }
}

With that in place, we can now attach our binding action directly to the slider:

struct ContentView: View {
    @State private var rating = 0.0

    var body: some View {
        Slider(value: $rating.onChange(sliderChanged))
    }

    func sliderChanged(_ value: Double) {
        print("Rating changed to \(value)")
    }
}

Use whichever one works best for you!

5. Stroking shapes when they mean to stroke the border

Here’s an easy one that many fall into: not understanding the difference between stroke() and strokeBorder(). You can see this problem in action when you try to stroke a large shape, such as this one:

Circle()
    .stroke(Color.red, lineWidth: 20)

Notice how you can’t see the left and right edges of the circle? That’s because the stroke() modifier centers its stroke on the edge of the shape, so a 20-point red stroke will draw 10 points of the line inside the shape and 10 points outside – causing it to hang off the screen.

In comparison, strokeBorder() draws its entire stroke inside the shape, so it will never be larger than the frame of the shape:

Circle()
    .strokeBorder(Color.red, lineWidth: 20)

There is one advantage to using stroke() over strokeBorder(), is that if you’re using a stroke style then stroke() will send back a new shape rather than a new view. This allows you to create certain effects that would otherwise have been impossible, such as stroking a shape twice:

Circle()
    .stroke(style: StrokeStyle(lineWidth: 20, dash: [10]))
    .stroke(style: StrokeStyle(lineWidth: 20, dash: [10]))
    .frame(width: 280, height: 280)

6. Using alerts and sheets with optionals

When you’re learning to present sheets and optionals, the easiest thing to do is bind their presentation to a Boolean like this:

struct User: Identifiable {
    let id: String
}

struct ContentView: View {
    @State private var selectedUser: User?
    @State private var showingAlert = false

    var body: some View {
        VStack {
            Button("Show Alert") {
                selectedUser = User(id: "@twostraws")
                showingAlert = true
            }
        }
        .alert(isPresented: $showingAlert) {
            Alert(title: Text("Hello, \(selectedUser!.id)"))
        }
    }
}

And again that works great – it’s easy to understand, and it works. But once you’re past the basics, you should consider moving over the optional versions instead, which remove the Boolean entirely and also remove a force unwrap. The only requirement is that whatever you’re watching must conform to Identifiable.

For example, we can show an alert whenever selectedUser changes, like this:

struct ContentView: View {
    @State private var selectedUser: User?

    var body: some View {
        VStack {
            Button("Show Alert") {
                selectedUser = User(id: "@twostraws")
            }
        }
        .alert(item: $selectedUser) { user in
            Alert(title: Text("Hello, \(user.id)"))
        }
    }
}

It makes your code simpler to read and write, and removes the extra worry of the force unwrap failing for some reason.

7. Trying to get “behind” your SwiftUI view

One of the most common problems people hit with SwiftUI is trying to change what’s behind their SwiftUI view. This usually starts with code something like this:

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .background(Color.red)
    }
}

That shows a white screen, with a red background color tightly fitting the text view. What many people want is for the whole screen to have the background color, and so they start reaching upwards to find what kind of UIKit view lives behind SwiftUI so they can futz with it.

Now, there absolutely is a UIKit view behind your code: it’s managed by a UIHostingController, which is a UIKit view controller like any other. But if you try to reach up into UIKit land, chances are you’ll hit problems either when your modifications cause SwiftUI to behave strangely, or if you ever try to use your code outside of iOS where UIKit isn’t available.

Instead, if you want your view to expand to fill all available space, just say so in SwiftUI:

Text("Hello, World!")
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color.red)
    .ignoresSafeArea()

8. Creating dynamic views using invalid ranges

Several of SwiftUI’s initializers allow us to pass ranges, which makes many kinds of views easy to create.

For example, if we wanted to show a list with four items in, we might write this:

struct ContentView: View {
    @State private var rowCount = 4

    var body: some View {
        VStack {
            List(0..<rowCount) { row in
                Text("Row \(row)")
            }
        }
    }
}

While that works fine, it becomes a problem if you try to change the range at runtime. You can see I made rowCount mutable using the @State property wrapper, so we can change it by adding a button to the start of the VStack:

Button("Add Row") {
    rowCount += 1
}
.padding(.top)

If you run the code now you’ll see that pressing the button shows a warning message in Xcode’s debug output, and nothing actually changes – it just doesn’t work.

To fix this, you should always either use the Identifiable protocol or provide a specific id parameter of your own, to make it clear to SwiftUI this range will change over time:

List(0..<rowCount, id: \.self) { row in
    Text("Row \(row)")
}

With that in place the button will work as expected.

What now?

I hope you found this article useful, no matter how much experience you have with SwiftUI. As I’ve said several times, some of the alternative code does work great, and although yes you can improve it that’s okay – there are always things you can improve, and if you’re just learning SwiftUI then don’t get hung up too much on using whatever code helps you get stuff done.

What problems did you hit while learning SwiftUI? Let me know on Twitter!

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure and A/B test your entire paywall UI without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI 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 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 Beyond Code

Was this page useful? Let us know!

Average rating: 4.4/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.