How to Master Swift and SwiftUI by Avoiding These 10 Deadly Sins

·

16 min read

Swift and SwiftUI are powerful tools for creating beautiful and responsive apps for iOS, macOS, watchOS, and tvOS. However, learning these technologies can also be challenging and frustrating, especially if you encounter some common pitfalls and errors along the way.

In this article, I will share with you 10 complex mistakes that I made while learning Swift and SwiftUI and developing new products. I will explain why they were mistakes, how they could have been avoided, what I learned from them, and what not to do and how to circumvent around doing them in the future.

Hopefully, by reading this article, you will be able to avoid making the same mistakes as I did, or at least learn how to fix them quickly and easily.

Photo by Brett Jordan on Unsplash

Mistake #1: Using @ObservedObject when I meant @StateObject

One of the first mistakes I made was using the wrong property wrapper for my view models. 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.

@State is used for simple values that are owned by the view, such as strings, booleans, numbers, etc. @StateObject is used for complex objects that are created by the view and need to survive across view updates, such as view models or data sources. @ObservedObject is used for complex objects that are passed into the view from somewhere else, such as parent views or environment objects.

The mistake I made was using @ObservedObject for my view models instead of @StateObject. This caused my view models to be recreated every time the view was redrawn, which led to unexpected behaviors and data loss. For example, if I had a text field bound to a property in my view model, typing something in it would trigger a view update, which would then create a new view model with the default value of the property, erasing what I typed.

The solution was to use @StateObject for my view models instead of @ObservedObject. This ensured that my view models were only created once when the view was initialized, and persisted across view updates. This way, I could keep track of the state and data of my views without losing them.

Mistake #2: Putting modifiers in the wrong order

Another mistake I made was putting modifiers in the wrong order when building my views. Modifiers are functions that modify the appearance or behavior of a view, such as .padding(), .font(), .foregroundColor(), etc. The order of these modifiers matters a lot, because they are applied from left to right1.

For example, if I wanted to create a text view with some padding around it and a blue background color, I might write something like this:

Text("Hello World")
    .padding()
    .background(Color.blue)

However, this would not give me the result I wanted. Instead of having a blue background around the text with some padding, I would have a blue background that fills the whole screen with some padding around it. This is because the .padding() modifier adds some space around the view, but also expands its size to fill its parent container. Then, the .background() modifier applies the color to the whole size of the view, including the padding.

The correct way to write this code would be to reverse the order of the modifiers:

Text("Hello World")
    .background(Color.blue)
    .padding()

This way, the .background() modifier applies the color only to the text itself, and then the .padding() modifier adds some space around it without expanding its size.

Mistake #3: Stroking shapes when I meant to stroke the border

Another mistake I made was stroking shapes when I meant to stroke the border of a view. Shapes are special views that can draw geometric forms on the screen, such as circles, rectangles, polygons, etc. They have a .stroke() modifier that can draw an outline around them with a given color and width.

For example, if I wanted to create a circle with a red outline and a white fill color, I might write something like this:

Circle()
    .stroke(Color.red)
    .fill(Color.white)

However, this would not give me the result I wanted. Instead of having a white circle with a red outline and a white fill color, I would have a red circle with no fill color at all. This is because the .stroke() modifier replaces the fill color of the shape with the stroke color, and then draws an outline around it1.

The correct way to write this code would be to use the .strokeBorder() modifier instead of .stroke():

Circle()
    .fill(Color.white)
    .strokeBorder(Color.red)

This way, the .fill() modifier applies the color to the shape itself, and then the .strokeBorder() modifier draws an outline around it without affecting the fill color.

Mistake #4: Using alerts and sheets with optionals

Another mistake I made was using alerts and sheets with optionals. Alerts and sheets are views that can be presented modally on top of other views, such as to show a message or a menu. They have two ways of being triggered: by a binding to a Boolean that shows them when it becomes true, or by a binding to an optional Identifiable object that shows them when it has a value.

For example, if I wanted to show an alert when a button is tapped, I might write something like this:

@State private var showAlert = false

Button("Show Alert") {
    showAlert = true
}
.alert("Hello", isPresented: $showAlert) {
    Button("OK") { }
}

However, this approach has some drawbacks. First, I need to create an extra state property to track whether the alert should be shown or not. Second, I need to set that property to true when I want to show the alert, and then SwiftUI will set it back to false when the alert is dismissed. Third, I need to pass a hard-coded title for the alert, which might not be very descriptive or dynamic.

A better way to write this code would be to use an optional Identifiable object as the condition for showing the alert:

struct Message: Identifiable {
    let id = UUID()
    let text: String
}

@State private var message: Message?
Button("Show Alert") {
    message = Message(text: "Hello")
}
.alert("Welcome", isPresented: $message != nil, presenting: message) { message in
    Button(message.text) { }
}

This way, I don’t need an extra Boolean property to track the alert state. Instead, I just set the message property to a value when I want to show the alert, and SwiftUI will set it back to nil when the alert is dismissed. Also, I can pass a custom title and text for the alert based on the message object.

The same principle applies to sheets. For example, if I wanted to show a sheet with some details about a user when a button is tapped, I might write something like this:

struct User: Identifiable {
    var id = "Taylor Swift"
}

@State private var showSheet = false
@State private var user: User?
Button("Show Sheet") {
    user = User()
    showSheet = true
}
.sheet(isPresented: $showSheet) {
    Text(user?.id ?? "Unknown")
}

However, this approach has the same drawbacks as before. A better way to write this code would be to use an optional Identifiable object as the condition for showing the sheet:

struct User: Identifiable {
    var id = "Taylor Swift"
}

@State private var user: User?
Button("Show Sheet") {
    user = User()
}
.sheet(item: $user) { user in
    Text(user.id)
}

This way, I don’t need an extra Boolean property to track the sheet state. Instead, I just set the user property to a value when I want to show the sheet, and SwiftUI will set it back to nil when the sheet is dismissed. Also, I can access the non-optional user value in the sheet closure without unwrapping it.

Mistake #5: Trying to get “behind” my SwiftUI view

Another mistake I made was trying to get “behind” my SwiftUI view and access some UIKit or AppKit components that were not exposed by SwiftUI. For example, I wanted to change the status bar style of my app depending on some conditions in my view. However, SwiftUI does not provide any built-in way of doing that.

I tried to find some workaround by using UIViewRepresentable or NSViewRepresentable protocols, which allow us to wrap UIKit or AppKit views in SwiftUI views. However, this did not work as I expected, because UIViewRepresentable and NSViewRepresentable views are not meant to be used as containers for other views. They are meant to be used as leaf views that wrap a single UIKit or AppKit view and expose its functionality to SwiftUI.

The solution was to use the .onAppear() and .onDisappear() modifiers on my SwiftUI view, and use them to call some methods that access the underlying UIHostingController or NSHostingController of my view. These methods can then use the UIKit or AppKit APIs to change the status bar style or any other property that is not exposed by SwiftUI.

For example, if I wanted to change the status bar style to light content when my view appears, and change it back to default when it disappears, I could write something like this:

struct ContentView: View {
    var body: some View {
        Text("Hello World")
            .onAppear {
                self.setStatusBarStyle(.lightContent)
            }
            .onDisappear {
                self.setStatusBarStyle(.default)
            }
    }

func setStatusBarStyle(_ style: UIStatusBarStyle) {
        // get the current UIHostingController
        guard let controller = UIApplication.shared.windows.first?.rootViewController as? UIHostingController<ContentView> else { return }
        // set its preferred status bar style
        controller.preferredStatusBarStyle = style
        // update the status bar appearance
        controller.setNeedsStatusBarAppearanceUpdate()
    }
}

Mistake #6: Creating dynamic views using invalid ranges

Another mistake I made was creating dynamic views using invalid ranges. SwiftUI allows us to create views dynamically using ForEach loops, which can iterate over collections of data and create a view for each element. However, ForEach loops require that the collections conform to the RandomAccessCollection protocol, which means they have a valid range of indices.

For example, if I wanted to create a list of text views using an array of strings, I could write something like this:

let names = ["Alice", "Bob", "Charlie"]

List {
    ForEach(0..<names.count) { index in
        Text(names[index])
    }
}

However, this code would crash if the names array was empty, because 0…<names.count would be an invalid range. The lower bound of the range (0) would be greater than the upper bound (0), which violates the precondition of the Range initializer.

The correct way to write this code would be to use the indices property of the array, which returns a valid range of indices for the collection:

let names = ["Alice", "Bob", "Charlie"]

List {
    ForEach(names.indices) { index in
        Text(names[index])
    }
}

This way, the code would work even if the names array was empty, because names.indices would return an empty range.

Alternatively, I could use a different overload of ForEach that takes an identifiable collection of data and a content closure, and avoid using indices altogether:

let names = ["Alice", "Bob", "Charlie"]

List {
    ForEach(names, id: \.self) { name in
        Text(name)
    }
}

This way, the code would work even if the names array had duplicate elements, because each element would be identified by its own value.

Mistake #7: Generating content queries using invalid syntax

Another mistake I made was generating content queries using invalid syntax. Content queries are strings that can be appended to a URL to specify what fields or data to be returned by an API. They are often formatted as JSON objects, which have a specific syntax and structure.

For example, if I wanted to query an API that returns information about GitHub repositories, I might write something like this:

let query = """
{
    "query": {
        "repositories": {
            "name": true,
            "owner": true,
            "stars": true
        }
    }
}
"""

let url = URL(string: "https://api.github.com/search?query=\(query)")!

However, this code would not work as I expected, because the query string is not a valid JSON object. JSON objects must use double quotes for keys and values, not single quotes. Also, JSON objects must not have trailing commas after the last key-value pair. Moreover, the query string must be properly encoded to be used in a URL, otherwise it might contain invalid characters or spaces that would break the URL.

The correct way to write this code would be to use a JSONEncoder to encode a Swift dictionary into a valid JSON object, and then use addingPercentEncoding(withAllowedCharacters:) to encode the query string for the URL:

let query = [
    "query": [
        "repositories": [
            "name": true,
            "owner": true,
            "stars": true
        ]
    ]
]

let encoder = JSONEncoder()
let data = try! encoder.encode(query)
let queryString = String(data: data, encoding: .utf8)!
let encodedQueryString = queryString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: "https://api.github.com/search?query=\(encodedQueryString)")!

Mistake #8: Using NavigationLink when I meant to use Button

Another mistake I made was using NavigationLink when I meant to use Button. NavigationLink is a view that can navigate to another view when tapped, such as to show a detail view or a settings view. Button is a view that can perform an action when tapped, such as to show an alert or a sheet.

For example, if I wanted to show an alert when a button is tapped, I might write something like this:

@State private var showAlert = false

NavigationLink("Show Alert", destination: Text("Hello World"), isActive: $showAlert)
    .onTapGesture {
        showAlert = true
    }
    .alert(isPresented: $showAlert) {
        Alert(title: Text("Hello World"))
    }

However, this code would not work as I expected, because NavigationLink is not meant to be used for showing alerts. Instead of showing an alert on top of the current view, it would navigate to a new view with the text “Hello World”. Also, the NavigationLink would look like a regular text view, not like a button.

The correct way to write this code would be to use a Button instead of a NavigationLink:

@State private var showAlert = false
Button("Show Alert") {
    showAlert = true
}
.alert(isPresented: $showAlert) {
    Alert(title: Text("Hello World"))
}

This way, the code would work as intended, showing an alert on top of the current view. Also, the Button would look like a button, with some style and animation.

The same principle applies to sheets. For example, if I wanted to show a sheet with some details about a user when a button is tapped, I might write something like this:

struct User: Identifiable {
    var id = "Taylor Swift"
}

@State private var user: User?
NavigationLink("Show Sheet", destination: Text(user?.id ?? "Unknown"), isActive: $user != nil)
    .onTapGesture {
        user = User()
    }
    .sheet(item: $user) { user in
        Text(user.id)
    }

However, this code would not work as I expected, because NavigationLink is not meant to be used for showing sheets. Instead of showing a sheet on top of the current view, it would navigate to a new view with the user’s name. Also, the NavigationLink would look like a regular text view, not like a button.

The correct way to write this code would be to use a Button instead of a NavigationLink:

struct User: Identifiable {
    var id = "Taylor Swift"
}

@State private var user: User?
Button("Show Sheet") {
    user = User()
}
.sheet(item: $user) { user in
    Text(user.id)
}

This way, the code would work as intended, showing a sheet on top of the current view. Also, the Button would look like a button, with some style and animation.

Mistake #9: Deleting rows from a list without updating the data source

Another mistake I made was deleting rows from a list without updating the data source. SwiftUI lists are data-driven, which means they reflect the state of the data source that they are bound to. If we want to delete a row from a list, we need to delete the corresponding item from the data source as well, otherwise we will get an inconsistency error.

For example, if I wanted to create a list of users and let the user swipe to delete them, I might write something like this:

struct User: Identifiable {
    var id = UUID()
    var name: String
}

struct ContentView: View {
    @State private var users = [
        User(name: "Alice"),
        User(name: "Bob"),
        User(name: "Charlie")
    ]
    var body: some View {
        List {
            ForEach(users) { user in
                Text(user.name)
            }
            .onDelete(perform: delete)
        }
    }
    func delete(at offsets: IndexSet) {
        // do nothing
    }
}

However, this code would not work as I expected, because the delete function does nothing. It does not update the users array to remove the deleted item. This would cause a fatal error when SwiftUI tries to render the list with an invalid number of rows.

The correct way to write this code would be to update the users array in the delete function, using the remove(atOffsets:) method:

struct User: Identifiable {
    var id = UUID()
    var name: String
}

struct ContentView: View {
    @State private var users = [
        User(name: "Alice"),
        User(name: "Bob"),
        User(name: "Charlie")
    ]
    var body: some View {
        List {
            ForEach(users) { user in
                Text(user.name)
            }
            .onDelete(perform: delete)
        }
    }
    func delete(at offsets: IndexSet) {
        users.remove(atOffsets: offsets)
    }
}

This way, the code would work as intended, deleting the item from the users array and updating the list accordingly.

Mistake #10: Confusing State and binding

Another mistake I made was confusing state and binding. State and binding are two property wrappers that are essential for building data-responsive views in SwiftUI. However, they have different purposes and behaviors, and using them incorrectly can lead to bugs and confusion.

State is a property wrapper that allows you to store values that can change over time, and automatically update any views that depend on them. State is used for simple properties that belong to a single view, such as strings, booleans, numbers, etc. State is accessible only to the view that owns it, and cannot be passed to other views.

Binding is a property wrapper that allows you to create a two-way connection between a view and its underlying data. Binding is used for complex properties that are owned by some other view or object, such as view models or environment objects. Binding is accessible to any view that receives it as a parameter, and can be used to read and write the value of the property.

The mistake I made was using state when I meant to use binding, or vice versa. For example, I tried to pass a state property to a child view, expecting it to update the parent view when it changes. However, this did not work, because state properties cannot be passed to other views. Instead, I should have used a binding property, which can be created by using the $ prefix on a state property.

For example, if I wanted to create a parent view with a text field and a child view with a slider, both bound to the same value, I might write something like this:

struct ParentView: View {
    @State var value = 0.5

var body: some View {
        VStack {
            TextField("Enter value", value: $value)
            ChildView(value: value) // wrong
        }
    }
}
struct ChildView: View {
    @State var value: Double // wrong
    var body: some View {
        Slider(value: $value)
    }
}

However, this code would not work as I expected, because the value property in the child view is marked with the @State property wrapper, which means it is independent from the value property in the parent view. Changing the slider would not update the text field, and vice versa.

The correct way to write this code would be to mark the value property in the child view with the @Binding property wrapper, and pass a binding to it from the parent view:

struct ParentView: View {
    @State var value = 0.5

var body: some View {
        VStack {
            TextField("Enter value", value: $value)
            ChildView(value: $value) // correct
        }
    }
}
struct ChildView: View {
    @Binding var value: Double // correct
    var body: some View {
        Slider(value: $value)
    }
}

This way, the code would work as intended, creating a two-way connection between the text field and the slider. Changing either one would update both views accordingly.

Conclusion

These are some of the complex mistakes that I made while learning Swift and SwiftUI and developing new products. I hope that by sharing them with you, I have helped you avoid making the same mistakes, or at least learn how to fix them quickly and easily.

Swift and SwiftUI are amazing technologies that enable us to create beautiful and responsive apps for various platforms. However, they also have some quirks and pitfalls that can be tricky to understand and overcome. By being aware of these common mistakes and how to avoid them, we can improve our skills and confidence as Swift and SwiftUI developers.

Thank you for reading this article. If you enjoyed it, please share it with your friends and colleagues. If you have any questions or feedback, please leave a comment below. Happy coding! 😊