Skip to main content
  1. SwiftUI in 100 Days Notes/

Day 53 - SwiftUI Binding, TextEditor and SwiftData Introduction

Today we’re starting a new project and this is where things start to get really serious because we’re going to learn an important new Swift skill that will come in handy as you build the project.

The skill we’re going to learn is SwiftData. It’s responsible for managing objects in a database, including reading, writing, filtering, sorting and more, and is extremely important in app development for iOS, macOS and beyond. Previously we wrote our data directly into UserDefaults, but that was just a short-term thing to help you learn, while SwiftData is more comprehensive.

Introduction #

In this project we will create an application to track which books you have read and what you think about them.

This time you will be introduced to SwiftData, Apple’s framework that works with databases. This project will be an introduction to SwiftData, but we will go into much more detail soon.

We will also create the first custom user component, a star rating widget that the user can tap to leave a rating for each book. This will mean introducing you to another property wrapper called @Binding.

For this play, start an xcode project called Bookworm. (Don’t choose the SwiftData option when creating the project.)

Creating Custom Component with @Binding #

You have already seen how SwiftUI’s @State property wrapper allows us to work with local value types and how @Bindable allows us to bind to properties inside observable classes. There is a third option with a rather confusing name: @Binding, which allows us to share a simple @State property of one view with others, so that they both point to the same Int, String, Boolean, etc.

When we create a toggle switch, we send a Boolean property like this

@State private var rememberMe = false

var body: some View {
    Toggle("Remember Me", isOn: $rememberMe)
}

When the user interacts with the switch they need to change the Boolean of the switch, but how do they remember which value to change?

This is where @Binding comes in: it allows us to store a single mutable value in a view that actually points to another value from somewhere else. In the Toggle example, the switch changes its local binding to a Boolean, but behind the scenes it is actually manipulating the @State property in the view - both reading and writing the same Boolean.

The difference between @Bindable and @Binding will be very confusing at first, but will eventually become clear.

To be clear, @Bindable is used when accessing a shared class that uses the @Observable macro. In a view you create it using @State so you have binding there, but when sharing it with other views you use @Bindable so SwiftUI can create binding there too.

On the other hand, @Binding is used when you have a simple, value type piece of data instead of a separate class. For example, we have a @State property that stores a Boolean, Int, etc. and you want to transfer it. This does not use the @Observable macro, so we cannot use @Bindable. Instead, we use @Binding so we can share Boolean or Int in several places.

This behavior makes @Binding extremely important when you want to create a custom UI component. In essence, UI components are just SwiftUI views like everything else, but @Binding is what sets them apart: although they have native @State properties, they also expose @Binding properties that allow them to interface directly with other views.

To demonstrate this, we’ll look at the code needed to create a custom button that stays down when pressed. Our basic implementation will be what you’ve seen before: a button with some padding, a linear gradient for the background, a Capsule clip shape, etc. Add this to ContentView.swift now;

struct PushButton: View {
    let title: String
    @State var isOn: Bool

    var onColors = [Color.red, Color.yellow]
    var offColors = [Color(white: 0.6), Color(white: 0.4)]

    var body: some View {
        Button(title) {
            isOn.toggle()
        }
        .padding()
        .background(LinearGradient(colors: isOn ? onColors : offColors, startPoint: .top, endPoint: .bottom))
        .foregroundStyle(.white)
        .clipShape(.capsule)
        .shadow(radius: isOn ? 0 : 5)
    }
}

The only exciting thing here is that I’m using properties for the two gradient colors, so they can be customized by whatever creates the button.

Now we can create one of these buttons as part of our main user interface as follows;

struct ContentView: View {
    @State private var rememberMe = false

    var body: some View {
        VStack {
            PushButton(title: "Remember Me", isOn: rememberMe)
            Text(rememberMe ? "On" : "Off")
        }
    }
}

Below this button there is a text view so we can monitor the status of the button - try running your code and see how it works.

Binding custom view not change state

What you will see is that touching the button does indeed affect the appearance of the button, but the text field does not reflect this change, it always says “Off”. Clearly something is changing because the appearance of the button changes when the button is pressed, but this change is not reflected in the ContentView.

What is happening here is that we have defined a one-way data flow: ContentView has the rememberMe Boolean, which is used to create a PushButton - the button has an initial value provided by ContentView. However, after the button is created it inherits control of the value, i.e. the isOn property changes between true or false inside the button, but does not pass this change back to the ContentView.

This is a problem, because we now have two sources of truth: ContentView stores one value and PushButton stores another. Fortunately, this is where @Binding comes in and allows us to create a two-way connection between the PushButton and whatever is using it, so that when one value changes, so does the other.

We only need to make two changes to switch to @Binding. First, change the isOn property in PushButton as follows;

@Binding var isOn: Bool

Second, change the way we create the button in ContentView as follows;

PushButton(title: "Remember Me", isOn: $rememberMe)

This adds a dollar sign before rememberMe, so we pass the binding itself, not the Boolean in it.

Now run the code again and you will see that everything works as expected.

Binding custom view change state

This is the power of `@Binding’: in the case of a button, it only changes one Boolean and has no idea that anything else is following that Boolean and acting on the changes.

Accepting Multiline Text Input with TextEditor #

We’ve used SwiftUI’s TextField view a few times before, and it’s great for when the user wants to enter short pieces of text. However, for longer pieces of text you may want to switch to using the TextEditor view instead: this view also expects two-way binding to a text string, but has the added advantage of allowing multiple lines of text.

Using TextEditor is actually easier than using TextField, mostly because there is nothing special in the configuration options, you can’t set the style or add placeholders, you just bind to a string. However, you have to be careful to make sure it doesn’t go outside the safe area, otherwise it will be hard to write; it makes more sense to use it in NavigationStack, Form etc.

For example, we can create the world’s simplest note application by combining TextEditor with @AppStorage as follows.

Tip : @AppStorage is not designed to store important information, so never use it for anything private.

But SwiftUI has a third option that works better in some situations.

When we create a TextField, we can optionally provide an axis on which it can grow. This means that the textfield starts as a normal single-line text field, but as the user types, it can grow just like the iMessage text box.

Here is how it looks like;

struct ContentView: View {
    @AppStorage("notes") private var notes = ""

    var body: some View {
        NavigationStack {
            TextField("Enter your text", text: $notes, axis: .vertical)
                .textFieldStyle(.roundedBorder)
                .navigationTitle("Notes")
                .padding()
        }
    }
}

You will use both of these approaches at some point, but at different times. While I like the automatic expansion of the TextField, sometimes it can be useful to be able to show your user a large text field, so they know in advance that they can type a lot there.

Tip : SwiftUI often changes the appearance of some things once inside a Form, so be sure to try them both inside and outside the Form to see how they change.

Introduction to SwifData #

SwiftUI is a powerful and modern framework for building great apps on all Apple platforms. SwiftData is a powerful and modern framework for storing, querying and filtering data. Wouldn’t it be nice if they somehow fit together?

Not only do they work perfectly together, but they require so little code that you won’t believe the results - you can create extraordinary things in just a few minutes.

First, the basics: SwiftData is an object graph and persistance framework. That’s a fancy way of saying that it allows us to define objects and their properties, and then read and write them to persistent storage.

On the surface this is similar to using Codable and UserDefaults, but much more advanced: SwiftData has the ability to sort and filter our data, and can work with much larger data - there is virtually no limit to how much data it can store. Even better, SwiftData can implement all kinds of more advanced functions for when you need them: iCloud synchronization, lazy loading of data, undo and redo, etc.

We’re only going to use a small part of SwiftData’s power in this project, but that will expand soon - I just want to give you a taste at first.

I asked you not to enable SwiftData support when you create your Xcode project, because while it removes some of the tedious setup code, it also adds a lot of extra sample code that makes no sense and needs to be deleted.

Instead, you’ll learn how to set up SwiftData manually. It consists of three steps, starting with defining the data we want to use in our application.

Please create the file named Student.swift and write the following code.

@Observable
class Student {
    var id: UUID
    var name: String

    init(id: UUID, name: String) {
        self.id = id
        self.name = name
    }
}

With two very small changes we can turn it into a SwiftData object (something that can save to a database, sync with iCloud, search, sort and more).

First we need to add another import at the top of the file.

import SwiftData

This says that we want to bring to Swift all the functionality from SwiftData. And now we want to change that.

@Observable
class Student {

To this

@Model
class Student {

…and that’s it. That’s all it needs to give SwiftData all the information it needs to load and save students. It can also now query them, delete them, associate them with other objects and more.

This class is called a SwiftData model: it defines the kind of data we want to work with in our applications. Behind the scenes @Model is built on top of the observation system using @Observable, which means it works really well with SwiftUI.

Now that we have defined the data we want to work with, we can move on to the second step of setting up SwiftData: writing a little Swift code to load this model. This code will tell SwiftData to prepare a storage space for us on the iPhone where it will read and write Student objects.

This is best done in the App struct. Every project, including all the projects we have done so far, has an App struct and it serves as the launch pad for the entire application we are running.

Since this project is called Bookworm, the App struct will be inside the BookwormApp.swift file. It should look like this;

import SwiftUI

@main
struct BookwormApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

You can see that it looks a bit similar to our normal view code: we still have an import SwiftUI, we still use a struct to create a custom type and our ContentView is right there. The rest is new and we are really interested in two parts:

  1. The @main line tells Swift that this is what starts our app. When the user launches our app from the iOS Home Screen, this is what internally starts the whole program.
  2. The WindowGroup part tells SwiftUI that our application can be displayed in many windows. This is of little use on iPhone, but becomes much more important on iPad and macOs.

Here we need to tell SwiftData to set all the storage for us to use and this again requires two very small changes.

First, we need to add import SwiftData next to import SwiftUI.

Second, we need to add a modifier to WindowGroup so that SwiftData is available everywhere in our application.

.modelContainer(for: Student.self) 

Model container is the name SwiftData gives to the place where it stores its data. When your application runs for the first time, this means that SwiftData needs to create the base database file, but on subsequent runs it will load the database it created earlier.

At this point you have seen how to create data models using @Model and how to create a model cotainer using the modelContainer() modifier. The third piece of the puzzle is called the model context, which is the “live” version of your data - when you load objects and modify them, these changes only exist in memory until they are saved. So, the task of the model context is that it resides in memory with all our data. So, the job of the model context is to allow us to work with all our data in memory, which is much faster than constantly reading and writing data to disk.

Every SwiftData application needs a model context to work in and we have already created ours, it is created automatically when we use the modelContainer() modifier. SwiftData automatically creates a model context for us called main context and stores it in the SwiftUI environment,

All our SwiftData configuration is done, now it’s time for the fun part: reading and writing data.

Retrieving information from SwiftData is done using a query - we describe what we want, how it should be sorted and whether any filters should be used, and SwiftData sends back all matching data. We need to make sure that this query stays up to date over time, so that our UI stays in sync as student is created and removed.

SwiftUI has a solution for this and - you guessed it - it’s another property wrapper. this time it’s called @Query and is available as soon as you import SwiftData into a file.

So, add an import for SwiftData at the top of ContentView.swift, then add this property to the ContentView struct;

@Query var students: [Student]

This looks like a normal Student array, but just adding @Query at the beginning is enough for SwiftData to load from the student model container - it automatically finds the main context placed in the environment and queries the container from there. We didn’t specify which students to load or how to sort the results, so we’ll take them all.

We can start using students like a normal Swift array, place this code in the view body;

NavigationStack {
    List(students) { student in
        Text(student.name)
    }
    .navigationTitle("Classroom")
}

You can run the code if you want, but there’s no point in doing so - the list will be empty because we haven’t added any data yet, so our database is empty. To fix this we will create a button below our list that adds new random students every time it is tapped, but we need a new property to access the model context we created earlier.

Add this property to ContentView;

@Environment(\.modelContext) var modelContext

Once you have done that, the next step is to add a button that creates random students and saves them in the model context. We will assign random names to the students by creating arrays firstNames and lastNames and then use randomElement() to select one of each.

Start by adding this toolbar to List;

.toolbar {
    Button("Add") {
        let firstNames = ["Ginny", "Harry", "Hermione", "Luna", "Ron"]
        let lastNames = ["Granger", "Lovegood", "Potter", "Weasley"]

        let chosenFirstName = firstNames.randomElement()!
        let chosenLastName = lastNames.randomElement()!

        // more code to come
    }
}

Note: Inevitably there will be people who will complain that I force unwrap calls to randomElement(), but we literally created the arrays by hand to have values - it will always succeed. If you hate force unwrap desperately, maybe replace them with nil coalescing and default values.

Now for the interesting part: we will create a Student object. Add this in place of the comment //more code to come.

let student = Student(id: UUID(), name: "\(chosenFirstName) \(chosenLastName)")

Finally we need to ask the model context to add this student, which means it will be saved. Add this last line to the button action.

modelContext.insert(student)

Finally, you can now run the app and try it out - click the Add button a few times to generate random students and you should see them placed somewhere in our list. Even better, when you restart the app you will see that the students are still there, because SwiftData has saved them automatically.

You might think this is a lot of learning for the end result, but now you know what models, model containers and model contexts are and you have seen how to add and query data. We’ll look more at SwiftData later in this project, but for now we’ve come a long way.

This was the last part of the overview for this project, so please reset your project before continuing. This means resetting ContentView.swift, Bookworm.swift and deleting Student.swift.


You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.

This article contains the notes I took for myself from the articles found at SwiftUI Day 53. Please use the link to follow the original lesson.