52.
Editing Items
Written by Joey deVilla
In the previous chapter, you added a key feature to Checklist: The ability to add items to the list. You’re no longer stuck with the five default items.
However, you still can’t fully edit an item. You can change its status from checked to unchecked, and vice versa, but you can’t change its name.
In this chapter, we’ll make checklist items fully editable, allowing the user to change both their names and checked status.
Changing how the user changes checklist items
Right now, when the user taps on a checklist item to toggle the item’s checked status. Tapping an unchecked item checks it, and tapping on a checked item unchecks it:
We’re going to give the user the ability to change either the name of a checklist item or its checked status. This will require making changes to how the app works.
Let’s look at the Reminders app that Apple includes on every iOS device as an example. In that app, tapping on an item’s name allows you to edit the name, while tapping on an item’s checkbox toggles its checked status:
Building this kind of user interface, as nice as it is, adds more complexity than an introductory tutorial should have. It would require changing the code in ChecklistView to support both showing the contents of the checklist and editing any given checklist item.
Instead, when the user taps a checklist item, we’ll take them to an edit screen that allows them to edit both its name and checked status:
The edit screen, which you’ll code in this chapter, will contain a Form view similar to the one you included in the Add new item screen. This Form will contain a view that allows the user to change the checklist item’s name and another view that allows the user to change its checked status.
With the changes that you’ll make, you’ll have a fully CRUD app by the end of this chapter. Checklist will be able to create, report, update and delete checklist items.
With that goal in mind, let’s get started!
Giving checklist rows their own view
First, we should look at the way that ChecklistView draws the list of checklist items onscreen. Here’s ChecklistView’s body property:
var body: some View {
NavigationView {
List {
ForEach(checklist.items) { checklistItem in
HStack {
Text(checklistItem.name)
Spacer()
Text(checklistItem.isChecked ? "✅" : "🔲")
}
.background(Color(UIColor.systemBackground)) // This makes the entire row clickable
.onTapGesture {
if let matchingIndex = self.checklist.items.firstIndex(where: { $0.id == checklistItem.id }) {
self.checklist.items[matchingIndex].isChecked.toggle()
}
}
}
.onDelete(perform: checklist.deleteListItem)
.onMove(perform: checklist.moveListItem)
}
.navigationBarItems(
leading: Button(action: { self.newChecklistItemViewIsVisible = true }) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add item")
}
},
trailing: EditButton()
)
.navigationBarTitle("Checklist")
}
.sheet(isPresented: $newChecklistItemViewIsVisible) {
NewChecklistItemView(checklist: self.checklist)
}
}
There’s a lot going on in this property. It:
- Draws each checklist item, including its name and checked status.
- Responds to presses on checklist items.
- Responds to the user moving a checklist item.
- Responds to the user deleting a checklist item.
- Draws the navigation bar and its items, including the Add item button, the Edit button, and the title.
- Responds to the user pressing the Add item button.
That’s already a lot of responsibilities in one place, and that means a lot of complexity.
It’s time for what computer scientists call functional decomposition. It’s a fancy academic term that means “breaking down a big complex task into a set of smaller, simpler tasks.” We’re going to apply this principle to ChecklistView to simplify it. We’ll do this by splitting ChecklistView’s set of responsibilities into two groups:
- Responsibilities that involve individual checklist item rows, namely drawing each checklist item, including its name and checked status and responding to presses on checklist items.
- Responsibilities that involve the checklist as a whole, namely responding to the user moving or deleting a checklist item, drawing the navigation bar and its items, including the Add item button, the Edit button and the title and responding to the user pressing the Add item button.
We’ll do this by defining a new view that will be responsible for drawing individual checklist rows. We’ll then call on this view from ChecklistView.
Defining the new row view
We’ll call this new view RowView, and we’ll put it in its own file, RowView.swift.
➤ Add a new file to the project by right-clicking or control-clicking on the Checklist folder in Xcode’s Project Explorer. Select New File… from the menu that appears:
➤ In the window that appears, make sure that you’ve selected iOS, then select SwiftUI View and click Next:
➤ Enter RowView into the Save As: field. Make sure that you’ve selected ChecklistItem in the Group menu and in the Targets menu, then click Create:
The project now has a new file named RowView.swift. Let’s start making changes to it.
➤ Open RowView.swift and change the definition of RowView to the following:
struct RowView: View {
@State var checklistItem: ChecklistItem
var body: some View {
HStack {
Text(checklistItem.name)
Spacer()
Text(checklistItem.isChecked ? "✅" : "🔲")
}
.background(Color(UIColor.systemBackground))
}
}
This change adds a property, checklistItem, to RowView. Since RowView is a struct and an initializer hasn’t been defined for it, it gets an implicit memberwise initializer.
Xcode will report an error in RowView_Previews : Missing argument for parameter ‘checklistItem’ in call…
This error came up because RowView_Previews was set up to instantiate the old version of RowView, which had no properties. Now that RowView has a property — checklistItem — you need to update the call to its memberwise intiializer to include the parameter that matches the property.
➤ In RowView.swift, change the preview section of the code to the following:
struct RowView_Previews: PreviewProvider {
static var previews: some View {
RowView(checklistItem: ChecklistItem(name: "Sample item"))
}
}
With this change, the error message will disappear.
Updating ChecklistView to use RowView
Our goal was to make each checklist row responsible for drawing itself. Now that we’ve defined RowView, the view that lets rows do just that, let’s update ChecklistView, the view that displays the rows in a list.
➤ Open ChecklistView.swift and in the body property of ChecklistView, change the lines of the ForEach view from this:
ForEach(checklist.items) { checklistItem in
HStack {
Text(checklistItem.name)
Spacer()
Text(checklistItem.isChecked ? "✅" : "🔲")
}
.background(Color(UIColor.systemBackground)) // This makes the entire row clickable
.onTapGesture {
if let matchingIndex = self.checklist.items.firstIndex(where: { $0.id == checklistItem.id }) {
self.checklist.items[matchingIndex].isChecked.toggle()
}
}
}
To this:
ForEach(checklist.items) { checklistItem in
RowView(checklistItem: checklistItem)
}
➤ Run the app. It will appear to run as before, which means that we’ve successfully moved the responsibility of drawing individual rows from ChecklistItem to RowView.
Appearances aren’t everything, however. Let’s see what happens if you tap on a row.
➤ Tap on any item in the list. You’ll see that it no longer checks or unchecks items.
Don’t worry; we’ll give individual rows the ability to respond to taps shortly.
Right now, the body property of ChecklistView should look like this:
var body: some View {
NavigationView {
List {
ForEach(checklist.items) { checklistItem in
RowView(checklistItem: checklistItem)
}
.onDelete(perform: checklist.deleteListItem)
.onMove(perform: checklist.moveListItem)
}
.navigationBarItems(
leading: Button(action: { self.newChecklistItemViewIsVisible = true }) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add item")
}
},
trailing: EditButton()
)
.navigationBarTitle("Checklist")
}
.sheet(isPresented: $newChecklistItemViewIsVisible) {
NewChecklistItemView(checklist: self.checklist)
}
}
Just as we made each row responsible for drawing itself by moving the row-drawing code to RowView, we’ll also make each row responsible for responding to user taps by moving the tap-response code to the same place.
Making rows respond to taps
Instead of checking or unchecking the corresponding item, tapping a row should take the user to a screen where they can edit both the item’s name and checked status:
You already have experience navigating between screens in a SwiftUI app. In Bullseye, you used the NavigationLink view to provide the user a link that, when tapped, takes them to another screen. We’ll use the same kind of view to take the user to an “Edit item” screen when they tap a checklist item.
➤ Open RowView.swift and update the body property of RowView to the following:
var body: some View {
NavigationLink(destination: EditChecklistItemView()) {
HStack {
Text(checklistItem.name)
Spacer()
Text(checklistItem.isChecked ? "✅" : "🔲")
}
.background(Color(UIColor.systemBackground))
}
}
You just took the HStack that defined a checklist row and put it inside a NavigationLink. This makes the entire row respond to taps from the user, and it will respond by taking the user to the view specified in the destination: parameter: A new instance of the EditChecklistItemView view.
Reminder: You created
EditChecklistItemViewand its file, EditChecklistItemView.swift, a couple of chapters ago. Right now,EditChecklistItemViewdefines a mostly empty screen that says, “Hello World.”
➤ Run the app. Tap on any item in the list. You’ll see the following:
Now that tapping on a checklist item takes you to EditChecklistItemView, it’s time to define that screen.
Defining EditChecklistItemView
Remember, when the user taps on a checklist item, we want them to see an “Edit” screen that looks like this:
This screen should have the following:
- A
TextFieldview containing the name of the selected checklist item. The user should be able to change the name of the checklist item by changing the text in this view. You used this control when creating the Add new item sheet in the previous chapter. - A control that displays the current checked status of the checklist item. The user should be able to change the checked status of the item by toggling this control. We’ll use a
Toggleview to create this control.
Just as we did with NewChecklistItemView, we’ll put these into a Form view that will organize and display them in a way that is most suitable for gathering user input.
➤ Open EditChecklistItemView.swift and change all the code below the ‘Import SwiftUI’ with the following:
struct EditChecklistItemView: View {
// Properties
// ==========
@State var checklistItem: ChecklistItem
// User interface content and layout
var body: some View {
Form {
TextField("Name", text: $checklistItem.name)
Toggle("Completed", isOn: $checklistItem.isChecked)
}
}
}
// Preview
// =======
struct EditChecklistItemView_Previews: PreviewProvider {
static var previews: some View {
EditChecklistItemView(checklistItem: ChecklistItem(name: "Sample item"))
}
}
➤ Try running the app. You’ll see that the new code has caused an error to pop up in RowView.
➤ Open RowView.swift. Look at RowView’s body property, and you’ll see a familiar error: Missing argument for parameter ‘checklistItem’ in call…
Before you read on, ask yourself: How did you fix this error the last time you saw it?
The reason that the NavigationLink line now has an error is because of a key change you made in EditChecklistItem. You gave it a property that doesn’t have an initial value: checklistItem. It’s there so that the NavigationLink can do more than just bring up the “Edit item” screen. It can also tell the “Edit item” screen which item it’s editing.
Since EditChecklistItem’s checklistItem property doesn’t have an initial value, we need to provide that value when creating the EditChecklistItemView view using its memberwise initializer. Let’s do that.
➤ In RowView.swift, update the body property of RowView to the following:
var body: some View {
NavigationLink(destination: EditChecklistItemView(checklistItem: checklistItem)) {
HStack {
Text(checklistItem.name)
Spacer()
Text(checklistItem.isChecked ? "✅" : "🔲")
}
.background(Color(UIColor.systemBackground))
}
}
With this change, the error should vanish. Let’s see the “Edit item” screen in action now!
➤ Run the app:
Let’s try editing the Walk the dog item.
➤ Tap the Walk the dog row. The “Edit item” screen will appear, containing the item’s current name and checked status:
➤ Change “Walk the dog” to “Walk the cat” and moved the Completed toggle from the “off” to the “on” position:
➤ Now that you’ve made those edits, tap the < Checklist button in the Navigation Bar to return to the checklist. Here’s what you’ll see:
Your changes vanished! The first checklist item’s name is still “Walk the dog” instead of “Walk the cat,” and it remains unchecked instead of checked.
What happened?
Retracing our steps so far
As you progress as a developer, you’re going to have more of these experiences where you’re coding away, and everything seems fine when suddenly, you run into an unexpected problem. Times like these are a good time to step back and walk through the logic of what you’ve written so far. Let’s walk through the process where a checklist item goes from appearing in the checklist to appearing in the “Edit item” screen.
➤ Open ChecklistView.swift and look at its body property. Here’s the part of body that draws all the items in the checklist:
ForEach(checklist.items) { checklistItem in
RowView(checklistItem: checklistItem)
}
The ForEach view goes through checklist.items, the array containing all the items in the checklist. For each item in that array, it creates a new RowView instance and, in doing so, sets that RowView instance’s checklistItem property to the current checklist item.
Each checklist item is an instance of ChecklistItem, which is a struct. That means that when you set a RowView instance’s checklistItem property, you’re giving the RowView instance its own separate copy of the checklist item.
➤ Open RowView.swift and look at its body property. Here’s the line in body that determines what happens when the user taps the row:
NavigationLink(destination: EditChecklistItemView(checklistItem: checklistItem)) {
The NavigationLink, when tapped, takes the user to the view specified in its destination: parameter. In this case, the destination is a new EditChecklistItemView view. In creating the new EditChecklistItemView, we set its checklistItem property to the checklist item used by RowView.
Once again, the checklist item that we’re passing to EditChecklistItemView is a struct — a value type — which means that we’re giving the EditChecklistItemView instance its own copy of the checklist item, which in turn is a copy of the checklist item from ChecklistView.
Here’s what you should take from all this retracing: When you’re editing a checklist item in EditChecklistItemView, you’re not editing an item in the checklist, but a separate, independent copy of that item. That’s why your changes to the “Walk the dog” item don’t appear in the checklist after you dismiss the “Edit item” window.
What we need is a way to pass a connection to the actual checklist item from ChecklistView to RowView to EditChecklistItemView instead of a mere copy. That way, any changes made in EditChecklistItemView will be made in the checklist.
@Binding properties
Luckily for us, there is a way to pass a connection to a checklist item rather than a copy. Let’s make use of it by starting with EditChecklistItemView.
Updating EditChecklistItemView
➤ Open EditChecklistItemView.swift. Change the line that defines the checklistItem property from this:
@State var checklistItem: ChecklistItem
To this:
@Binding var checklistItem: ChecklistItem
You’ve just changed checklistItem from a @State property to a @Binding property. As a @State property, checklistItem was a property that belonged to EditChecklistItemView. When a RowView instance passes a checklist item to an EditChecklistItemView instance via the checklistItem item property, it makes a copy of RowView’s checklist item. Any changes made to the checklist item in EditChecklistItemView aren’t reflected in the matching checklist item in RowView, which is what we want.
As a @Binding property, checklistItem is a connection to another object’s property. Now, when a RowView instance passes a checklist item to an EditChecklistItemView via the checklistItem item property, any changes made to the checklist item in EditChecklistItemView will be reflected in the matching checklist item in RowView.
Like @State properties, structs can change their own @Binding properties.
Change checklistItem from a @State property to a @Binding property cause an error in the preview code. That’s because it code is trying to pass put a checklist item into a property that now expects a binding to a checklist item:
➤ Update the preview code in EditChecklistItemView to the following:
struct EditChecklistItemView_Previews: PreviewProvider {
static var previews: some View {
EditChecklistItemView(checklistItem: .constant(ChecklistItem(name: "Sample item")))
}
}
Wrapping ChecklistItem(name: "Sample item") inside the .constant function creates a binding to a checklist item, which is the kind of value that the checklistItem property expects.
This completes all the changes we need to make to EditChecklistItemView. It’s time to edit the blueprint for objects that pass checklist items to EditChecklistItemView: RowView.
Updating RowView
➤ Open RowView.swift. Change the line that defines the checklistItem property from:
@State var checklistItem: ChecklistItem
To:
@Binding var checklistItem: ChecklistItem
This should give you a sense of déjà vu, and with good reason. You made the exact same changes in EditChecklistItemView! The connection to a checklist item that EditChecklistItemView receives from RowView is, in fact, a connection that RowView will receive from ChecklistItemView.
Since RowView will not be passing a checklist item to EditChecklistItemView, but a binding to a checklist item, we need to specify that.
➤ Change the NavigationLink line in RowView’s body property from:
NavigationLink(destination: EditChecklistItemView(checklistItem: checklistItem)) {
To:
NavigationLink(destination: EditChecklistItemView(checklistItem: $checklistItem)) {
The change is so subtle that you might have missed it. Instead of setting EditChecklistItemView’s checklistItem property to checklistItem, you’re now setting it to $checklistItem. The $ makes the difference: checklistItem is a checklist item, and $checklistItem is a binding to a checklist item.
Just as with EditChecklistItemView, changing RowView’s checklistItem property into a @Binding created an error in the preview code:
Once again, it’s a matter of changing its code so that it passes a binding to a checklist item and not just a checklist item to RowView.
➤ Update the preview code in RowView to the following:
struct RowView_Previews: PreviewProvider {
static var previews: some View {
RowView(checklistItem: .constant(ChecklistItem(name: "Sample item")))
}
}
We’re done making the necessary changes to RowView. But, there’s one more object blueprint to edit: ChecklistView.
Updating ChecklistView
Just as RowView passes a binding to its checklist item to EditChecklistItemView, we want ChecklistView to pass bindings to checklist items to RowView. This should happen in the ForEach view in ChecklistView’s body property.
➤ Open ChecklistView.swift and look at the ForEach view in the body property:
ForEach(checklist.items) { checklistItem in
RowView(checklistItem: checklistItem)
}
Since the checklistItem property of RowView now holds bindings to checklist items instead of checklist items, the current code causes Xcode to display an error message:
This should easily be fixed by changing the value we put into RowView’s checklistItem property from a checklist item into a binding to a checklist item by prefacing it with a $ character.
➤ Change the ForEach view in the body property to the following:
ForEach(checklist.items) { checklistItem in
RowView(checklistItem: $checklistItem)
}
That won’t work either:
The error message, “Use of unresolved identifier ‘$checklistItem’”, is Xcode’s way of saying: “I have no idea what you mean by $checklistItem.” The problem is that you can only create a binding to a @State or @Binding variable, and the checklistItem inside ForEach’s braces is neither.
A workaround
We need a way for ChecklistView to go through each item in the checklist and give RowView a binding to each item. SwiftUI doesn’t (yet) have a built-in way to do this, but we’ve written some extensions that make up for this shortcoming.
➤ Open the Resources folder that comes with this book, and then open the Checklist subfolder. Inside that folder, you’ll find a folder named Extensions. Drag this folder onto the yellow Checklist folder in the Xcode project:
➤ When the Choose options for adding these files: window appears, make sure that the Copy items if needed checkbox is checked, the Create groups option is selected and that the Checklist item in the Add to targets menu is checked:
The project should look similar to this in Xcode’s Project Navigator:
Updating EditChecklistItemView
Now that the project has the necessary extensions, let’s make use of them!
➤ Open ChecklistView.swift. Change the ForEach view in the body property to:
ForEach(checklist.items) { index in
RowView(checklistItem: self.$checklist.items[index])
}
With the help of the extensions, this code goes through checklist.items and passes a binding to each item to RowView.
You’ll also need to make a change to the preview code in EditChecklistItemView.
➤ Open EditChecklistItemView.swift. Change its preview code to the following:
struct EditChecklistItemView_Previews: PreviewProvider {
static var previews: some View {
EditChecklistItemView(checklistItem: .constant(ChecklistItem(name: "Sample item")))
}
}
➤ Run the app. It should display the default list of items:
➤ Select a checklist item to edit by tapping on one of them. In this example, I tapped on the first item, “Walk the dog” and edited it by changing its name to “Walk the cat” and changing its status to completed:
➤ Tap on the < Checklist button in the upper left-hand corner of the screen to return to the checklist. You’ll see that this time, your edits remain!
Congratulations — Checklist is now CRUD!
A glitch in the Simulator
The perils of new platforms
Tech companies these days have a tendency to release products a little earlier than they probably should, largely because of the advantages that come from being “first to market.” Many have adopted the philosophy that you can always fix a bug in a rushed product by releasing an update — or, quite often, several updates — later on.
This “release early, release often” approach is doubly true for developer tools and platforms. Unlike consumer products, which usually have a small set of use cases, developers use their tools in many different ways, and there’s no way to predict how they’ll use any given feature. The vendors who make developer tools often find it more practical to treat their users as “gamma testers” — in the Greek alphabet, gamma is the next letter after beta — and rely on their feedback to find out where the bugs are.
SwiftUI is a brand-new platform, and as one of the earliest developers to use it, you should expect to encounter some bugs. If you’ve been playing with the app, you may have already encountered the one that you’re about to deal with next.
The glitch
At the time I’m writing this, there’s a glitch in the Simulator that may make you think that something’s wrong with the app. Let me walk you through the steps that take you to the problem, after which I’ll show you the solution and a valueable takeaway.
➤ In the Simulator, run the app:
➤ Select an item from the list, and tap it. You’ll be taken to the Edit screen for that item. In this case, I chose “Walk the dog,” the first item in the list:
➤ You can choose to edit the name of the item or change its “checked” status; it doesn’t matter. Return to the checklist by tapping the Checklist button.
➤ And now, the glitch: Tap the same list item. This time, you won’t be taken to the Edit screen. You’ll still be on the checklist screen, with the list item mocking you with its highlighted form. Here’s what it looks like if your simulator is in light mode…
…and here’s what it looks like in dark mode:
The way to get that list item unstuck is to tap on any other list item.
➤ Try tapping on any other list item, and then go back to the checklist. The list item you originally tapped should now be unstuck, but the item you just tapped now won’t respond to being tapped anymore.
After reviewing my code and looking for code that might render list items unresponsive to taps, an idea came to me: What if this happens only in the Simulator?
➤ Run the app again, but this time, do it on a device. Select an item from the list, tap it, return to the checklist and tap the item again. It works!
I decided to confirm my solution by searching for the issue online. Using the search term “swiftui list click twice,” I found a posting where people had the same issue with list items that used a NavigationLink to navigate to another screen when tapped. Like me, they encountered the glitch on the Simulator, but not a real device.
I encountered the Simulator glitch on the iPhone 8 Simulator, which was running iOS 13.3. You can see which version of iOS your Simulator is running by going to Settings on the Simulator, then selecting General and then About. This screen will show you the version of iOS that your Simulator is running in the Software Version row.
How to deal with SwiftUI bugs
You’ll find that gaining experience is the best way to sharpen your programming instincts. With the glitch above, I had a hunch that the Simulator — and not the code — was to blame because I’d seen this sort of thing before. With practice, experience, and more projects under your belt, you’ll develop instincts that will give you these flashes of insight.
However, there will be times when instinct and experience aren’t enough. You will eventually do some programming work with something unknown to you — it could be a different library or framework, an unfamiliar programming language, or even a whole new platform. What do you do when this happens?
Just as developer tool vendors often rely on the developer community to find bugs, you can also rely on the developer community to help you find ways around them. Programming has a strong tradition of sharing information, and you’ll find that iOS programmers are generally an energetic and friendly bunch. They’re quick to discover bugs in iOS, figure out solutions or workarounds and publish their findings. If you frequent their online hangouts, you’re quite likely to find answers to your questions.
Whenever you find yourself stuck while working on a project in The iOS Apprentice, the first place you should go for help is the online forum for this book. It’s the perfect place to ask questions if anything we’ve written has you confused or isn’t working for you. A team of moderators keeps an eye on the forums, ensuring when you ask a question, you’re not just screaming into the void. When they see a new question, they alert the authors — Yours Truly included — and we’ll gladly help out.
Note: We’ll update this edition of The iOS Apprentice when Apple updates iOS 13 and makes fixes, and those updates are included in the cost of the book! Make sure that you check this book’s page on the raywenderlich.com site for new versions.
There are other places online that you may find useful — not just for dealing with SwiftUI bugs, but also for getting answers for your iOS programming questions and learning about other aspects of iOS programming. Here are some good starting points:
- raywenderlich.com’s forums: These cover not just iOS topics, but Android, Unity and Flutter development as well.
- Stack Overflow: This is the best-known of all the developer forum sites out there. If you’re a programmer, you’ll eventually end up here looking for (or possibly dispensing) answers. You’ll probably peruse the questions tagged iOS, Swift, and SwiftUI often.
- Reddit’s iOSProgamming subreddit: The biggest collection of forums online has one dedicated to iOS programming. It’s so active that collectively, its readers have filed over 60,000 “radars” — that’s Apple’s term for bug reports and requests for features or enhancements.
Key points
In this chapter, you:
-
Created a new view, allowing rows to draw themselves and respond to taps independently.
-
Updated Checklist’s user interface to support both checking items and editing their names.
-
Defined the “Edit item” screen.
-
Learned about how
@Bindingscan be used to shared properties among screens. -
Used extensions to get around a rough edge in SwiftUI.
-
Brought the app to the point where it can list checklist items, create a new checklist item, edit an existing checklist item and delete checklist items. You have a full CRUD app now!
-
Learned about a glitch in the Simulator, how to work around it and how to deal with SwiftUI bugs.
You’ll find the project files for the app at this stage under 52 – Editing Items in the Source Code folder.
In the next chapter, we’ll add a much-needed capability to checklist: The ability to remember list items between sessions.