14.
Edit Items
Written by Eli Ganim
Adding new items to the list is a great step forward for the app, but there are usually three things an app needs to do with data:
- Add new items (which you’ve tackled).
- Deleting items (you allow that with swipe-to-delete).
- Editing existing items (uhh…).
The last is useful when you want to rename an item from your list — afterall, we all make typos.
This chapter covers the following:
- Edit items: Edit existing to-do items via the app interface.
- Refactor the code: Using Xcode’s built-in refactoring capability to rename code to be easily identifiable.
- One more thing: Fix missed code changes after the code refactoring using the Find navigator.
Editing items
You could make a completely new Edit Item screen, but it would be needless duplication of work — the edit screen woul work mostly the same as the Add Item screen. The only difference is that it doesn’t start out empty — instead, it works with an existing to-do item.
So, let’s re-use the Add Item screen and make it capable of editing an existing ChecklistItem object.
For the edit option, when the user presses Done, you won’t have to make a new ChecklistItem object, instead, you will simply update the text in the existing ChecklistItem.
You’ll also tell the delegate about these changes so that it can update the text label of the corresponding table view cell.
Exercise: What changes would you need to make to the Add Item screen to enable it to edit existing items?
Answer:
- The screen title must be changed to Edit Item.
- You must be able to pass it an existing
ChecklistItemobject. - You have to place the
ChecklistItem’s text into the text field. - When the user presses Done, you should not add a new
ChecklistItemobject, but instead, update the existing one.
There is a bit of a user interface problem, though… How will the user actually open the Edit Item screen? In many apps that is done by tapping on the item’s row, but in Checklists that already toggles the checkmark on or off.
To solve this problem, you’ll have to revise the UI a little first.
Revising the UI to allow editing
When a row is given two functions, the standard approach is to use a detail disclosure button for the secondary task:
Tapping the row itself will still perform the row’s main function — in this case, toggling the checkmark. But tapping the disclosure button will open the Edit Item screen.
Note: An alternative approach is in Apple’s Reminders app, where the checkmark is on the left and tapping only this part of the row will toggle the checkmark. Tapping anywhere else in the row will bring up the Edit screen for that item.
There are also apps that can toggle the whole screen into “Edit mode” and then let you change the text of an item inline. Which solution you choose depends on what works best for your data.
➤ Go to the table view cell in the storyboard for the Checklists scene and in the Attributes inspector set its Accessory to Detail Disclosure.
Instead of the checkmark, you’ll now see a chevron (>) and a blue info button on the cell. This means you’ll have to place the checkmark somewhere else.
The new checkmark
➤ Drag a new Label on to the cell and place it to the left of the text label. Give it the following attributes:
- Text: √ (you can type this with Alt/Option+V)
- Font: Helvetica Neue, Bold, size 22
- Tag: 1001
You’ve given this new label its own tag, so you can easily find it later.
If typing Option-V does not work for you, or you’d prefer a different image, choose Edit ▸ Emoji & Symbols from the Xcode menu bar.
Use the search bar to search for “check” – or whatever takes your fancy. (Note that not all of these special symbols may actually work on your iPhone.)
➤ Resize the text label so that it doesn’t overlap the checkmark or the disclosure button. It should be about 215 points wide.
➤ Adjust the Auto Layout constraints on the button original label and set new constraints on the new label after you’ve positioned everything to your satisfaction.
The best course of action is probably to retain all the existing layout constraints on the old label except for the leading one. You can delete any constraints by selecting the control (the label in this case) and then switching to the Size inspector, selecting the relevant constraint and tapping Delete.
You can then set up Auto Layout constraints for the new label for width, left, top, right, and bottom via the Add New Constraints button to position everything correctly.
The design of the prototype cell should now look similar to this:
➤ In ChecklistViewController.swift, change configureCheckmark(for:with:) to:
func configureCheckmark(for cell: UITableViewCell,
with item: ChecklistItem) {
let label = cell.viewWithTag(1001) as! UILabel
if item.checked {
label.text = "√"
} else {
label.text = ""
}
}
Instead of setting the cell’s accessoryType property, this now changes the text in the new label.
➤ Run the app and you’ll see that the checkmark has moved to the left. There is also a blue detail disclosure button on the right. Tapping the row still toggles the checkmark, but tapping the blue button doesn’t do anything… yet.
The edit screen segue
Next, you’re going to make the detail disclosure button open the Add/Edit Item screen. This is pretty simple because Interface Builder also allows you to make a segue for a disclosure button.
➤ Open the storyboard. Select the table view cell for the Checklists scene and Control-drag to the Add Item scene to make a segue. From the pop-up, choose Show from the Accessory Action section (not from Selection Segue):
There should now be two segues going from the Checklists screen to the navigation controller. One is triggered by the + button, the other by the detail disclosure button from the prototype cell.
For the app to make a distinction between these two segues, they must have unique identifiers.
➤ Give this new segue the identifier EditItem (in the Attributes inspector).
If you run the app now, tapping the blue ⓘ button will also open the Add Item screen. But the Cancel and Done buttons won’t work.
Exercise: Can you explain why not?
Answer: You haven’t set the delegate yet. Remember that you set the delegate in prepare(for:sender:), but only for when the + button is tapped to perform the AddItem segue. You haven’t done the same for this new EditItem segue.
Before you do that though, you should first make the Add Item screen capable of editing existing ChecklistItem objects.
Updating the Add Item screen to handle editing
➤ Add a new property for a ChecklistItem object below the other instance variables in AddItemViewController.swift:
var itemToEdit: ChecklistItem?
This variable contains the existing ChecklistItem object that the user will edit. But when adding a new to-do item, itemToEdit will be nil. That is how the view controller will make the distinction between adding and editing.
Because itemToEdit can be nil, it needs to be an optional. That explains the question mark.
➤ Update viewDidLoad() in AddItemViewController.swift as follows:
override func viewDidLoad() {
. . .
if let item = itemToEdit {
title = "Edit Item"
textField.text = item.text
}
}
Recall that viewDidLoad() is called by UIKit when the view controller is loaded from the storyboard, but before it is shown on the screen. That gives you time to put the user interface in order.
In editing mode, when itemToEdit is not nil, you change the title in the navigation bar to “Edit Item.” You do this by changing the title property.
Each view controller has a number of built-in properties and this is one of them. The navigation controller looks for the title property and automatically changes the text in the navigation bar.
You also set the text in the text field to the value from the item’s text property.
if let
You cannot use optionals like you would regular variables. For example, if viewDidLoad() had the following code:
textField.text = itemToEdit.text
Xcode would complain with the error message, “Value of optional type ChecklistItem? not unwrapped.”
That’s because itemToEdit is the optional version of ChecklistItem.
In order to use it, you first need to unwrap the optional. You do that with the following special syntax:
if let temporaryConstant = optionalVariable {
// temporaryConstant now contains the unwrapped value of the
// optional variable. temporayConstant is only available from
// within this if block
}
If the optional is not nil, then the code inside the if statement is performed.
There are a few other ways to read the value of an optional, but using if let is the safest: if the optional has no value – i.e. it is nil – then the code inside the if let block is skipped over.
The new code you added to viewDidLoad can also be written like this:
if let itemToEdit = itemToEdit {
title = "Edit Item"
textField.text = itemToEdit.text
}
Looks a bit weird, doesn’t it? Why are we assigning the value from itemToEdit back again to itemToEdit? And how come the compiler doesn’t complain about optional unwrapping now if we write the code like that?
The above practice is called variable shadowing — you create a “shadow” instance of the itemToEdit variable just for the duration of the if condition and that shadow instance is an unwrapped instance of the originally optional itemToEdit variable.
So, when you refer to itemToEdit when assigning text to the text field, you are actually referring to the unwrapped instance of the variable instead of the original optional instance.
This might be a bit confusing if you are new to Swift and optionals. So, whether you use variable shadowing to unwrap optionals, or not, is entirely up to you. Some engineers prefer shadowing because then the code is clear about the variable being referred to in the code at all times since the same variable name is used for both the optional and unwrapped versions.
The AddItemViewController is now capable of recognizing when it needs to edit an item. If the itemToEdit property is given a ChecklistItem object, then the screen magically changes into the Edit Item screen. But where do you set that itemToEdit property? In prepare-for-segue, of course! That’s the ideal place for placing values into the properties of the new screen before it becomes visible.
Setting the item to be edited
➤ Change prepare(for:sender:) in ChecklistViewController.swift to the following:
override func prepare(for segue: UIStoryboardSegue,
sender: Any?) {
if segue.identifier == "AddItem" {
. . .
} else if segue.identifier == "EditItem" {
let controller = segue.destination
as! AddItemViewController
controller.delegate = self
if let indexPath = tableView.indexPath(
for: sender as! UITableViewCell) {
controller.itemToEdit = items[indexPath.row]
}
}
}
As before, you get the AddItemViewController via the segue’s destination. You also set the view controller’s delegate property so you’re notified when the user taps Cancel or Done. Nothing new there. This is the same as for the AddItem segue. This is the interesting new bit:
if let indexPath = tableView.indexPath(
for: sender as! UITableViewCell){
controller.itemToEdit = items[indexPath.row]
}
You’re in the prepare(for:sender:) method, which has a parameter named sender. This parameter contains a reference to the control that triggered the segue, in this case, the table view cell whose disclosure button was tapped.
You use that UITableViewCell object to find the table view row number by looking up the corresponding index path using tableView.indexPath(for:).
The return type of indexPath(for:) is IndexPath?, an optional, meaning it can possibly return nil. That’s why you need to unwrap this optional value with if let before you can use it.
Once you have the index path, you obtain the ChecklistItem object to edit, and you assign this to AddItemViewController’s itemToEdit property.
Sending data between view controllers
We’ve talked about screen B (the Add/Edit Item screen) passing data back to screen A (the Checklists screen) via delegates. But here, you’re passing a piece of data the other way around – from screen A to screen B – namely, the ChecklistItem to edit.
Data transfer between view controllers works two ways:
-
From A to B. When screen A opens screen B, A can give B the data it needs. You simply make a new instance variable in B’s view controller. Screen A then puts an object into this property right before it makes screen B visible, usually in
prepare(for:sender:). -
From B to A. To pass data back from B to A you use a delegate.
This illustration shows how screen A sends data to screen B by putting it into B’s properties, and how screen B sends data back to the delegate:
I hope the flow between view controllers is starting to make sense to you now. You’re going to do this sort of thing a few more times in this app, just to make sure you get comfortable with it.
Making iOS apps is all about creating view controllers and sending messages between them, so you want this to become second nature.
➤ With these steps done, you can now run the app. A tap on the + button opens the Add Item screen as before. But tap the accessory button on an existing row and the screen that opens is named Edit Item. It already contains the to-do item’s text:
Enabling the Done button for edits
One small problem: The Done button in the navigation bar is initially disabled. This is because you originally set it to be disabled in the storyboard.
➤ Change viewDidLoad() in AddItemViewController.swift to fix this:
override func viewDidLoad() {
super.viewDidLoad()
if let item = itemToEdit {
title = "Edit Item"
textField.text = item.text
doneBarButton.isEnabled = true // add this line
}
}
When in edit mode, you simply enable the Done button since you are guaranteed to be passed some text for the item.
The problems don’t end here, though. Run the app, tap a row to edit it, and press Done. Instead of changing the text on the existing item, a brand new to-do item with the new text is added to the list.
How come? You didn’t write the code yet to update the data model! So, the delegate always thinks it needs to add a new row. To solve this, you will add a new method to the delegate protocol.
Handling edits in the delegate protocol
➤ Add the following line to the protocol section in AddItemViewController.swift:
func addItemViewController(_ controller: AddItemViewController,
didFinishEditing item: ChecklistItem)
The full protocol now looks like this:
protocol AddItemViewControllerDelegate: class {
func addItemViewControllerDidCancel(
_ controller: AddItemViewController)
func addItemViewController(
_ controller: AddItemViewController,
didFinishAdding item: ChecklistItem)
func addItemViewController(
_ controller: AddItemViewController,
didFinishEditing item: ChecklistItem)
}
There is a method that is invoked when the user presses Cancel and two methods for when the user presses Done.
After adding a new item you call didFinishAdding, but when editing an existing item, the new didFinishEditing method should now be called instead.
By using different methods, the delegate (the ChecklistViewController), can make a distinction between those two situations.
➤ In AddItemViewController.swift, change the done() method to:
@IBAction func done() {
if let item = itemToEdit {
item.text = textField.text!
delegate?.addItemViewController(self,
didFinishEditing: item)
} else {
let item = ChecklistItem()
item.text = textField.text!
delegate?.addItemViewController(self, didFinishAdding: item)
}
}
First the code checks whether the itemToEdit property contains an object — you should recognize the if let syntax for unwrapping an optional.
If the optional is not nil, you put the text from the text field into the existing ChecklistItem object and then call the new delegate method.
In the case that itemToEdit is nil, the user is adding a new item and you do the stuff you did before (inside the else block).
Implementing the new delegate method
➤ Try to build the app. It won’t work.
Xcode says “Build Failed” but there don’t seem to be any error messages in AddItemViewController.swift. So what went wrong?
You can see all errors and warnings from Xcode in the Issue navigator.
The error is apparently in ChecklistViewController because it does not implement a method from the protocol. That is not so strange because you just added the new addItemViewController(_:didFinishEditing:) method to the delegate protocol. But you did not yet tell the view controller, which plays the role of the delegate, what to do about it.
Note: The exact error message in my version of Xcode is “Method … has different argument names from those required by protocol ….” That’s a bit of a strange error message, wouldn’t you say? It doesn’t really describe what’s wrong, just what the compiler is confused about.
As you write your own apps, you’ll probably run into other strange or even undecipherable error messages. This should get better in time. The Swift compiler is quite new at the job and still needs to work on its bedside manner.
➤ Add the following to ChecklistViewController.swift and the compiler error will be history:
func addItemViewController(
_ controller: AddItemViewController,
didFinishEditing item: ChecklistItem) {
if let index = items.firstIndex(of: item) {
let indexPath = IndexPath(row: index, section: 0)
if let cell = tableView.cellForRow(at: indexPath) {
configureText(for: cell, with: item)
}
}
navigationController?.popViewController(animated:true)
}
The ChecklistItem object already has the new text — it was put there by done() — and the cell for it already exists in the table view. But you do need to update the label for its table view cell.
So, in this new method you look for the cell that corresponds to the ChecklistItem object and, using the configureText(for:with:) method you wrote earlier, tell it to refresh its label.
The first statement is the most interesting:
if let index = items.firstIndex(of: item) {
In order to create the IndexPath that you need to retrieve the cell, you first need to find the row number for this ChecklistItem. The row number is the same as the index of the ChecklistItem in the items array — you can use the firstIndex(of:) method to return that index.
Now, it won’t happen here, but in theory it’s possible that you use firstIndex(of:) on an object that is not actually in the array. To account for the possibility, firstIndex(of:) does not return a normal value, it returns an optional. If the object is not part of the array, the returned value is nil.
That’s why you need to use if let here to unwrap the return value from firstIndex(of:).
➤ Try to build the app. Oops, spoke too soon! Xcode has found another reason to complain: “Cannot invoke index with an argument list of type blah blah blah.” What does that mean?
This error is displayed because you can’t use firstIndex(of:) on just any array (or collection of objects). An object has to be “equatable” if you are to use firstIndex(of:) on an array of that object type.
This is because firstIndex(of:) must be able to somehow compare the object that you’re looking for against the objects in the array, to see if they are equal.
Your ChecklistItem object does not have any functionality for that yet. There are a few ways you can fix this, but we’ll go for the easy one.
➤ In ChecklistItem.swift, change the class line to:
class ChecklistItem: NSObject {
If you’ve programmed in Objective-C before, you’ll be familiar with NSObject.
Almost all objects in Objective-C programs are based on NSObject. It’s the most basic building block provided by iOS, and it offers a bunch of useful functionality that standard Swift objects don’t have.
You can write many Swift programs without having to resort to NSObject, but in times like these it comes in handy.
Building ChecklistItem on top of NSObject is enough to satisfy the “equatable” requirement. In case you’re interested, the other way to do this would have been to specify that ChecklistItem conforms to the Equatable protocol. But then, you’d have to implement an additional method to indicate how the comparison of two ChecklistItem instances would happen. So going with NSObject conformance is easier for the time being.
➤ Run the app again and verify that editing items works now. Excellent!
Refactoring the code
At this point, you have an app that can add new items and edit existing items using the combined Add/Edit Item screen. Pretty sweet!
Given the recent changes, the name AddItemViewController is not appropriate anymore as this screen is now used to both add and edit items.
I suggest you rename it to ItemDetailViewController.
Renaming the view controller
Most IDEs (or Integrated Development Environments) such as Xcode have a feature named refactoring, which allows you to change the name of a class, method, or variable throughout the entire project, safely. Unfortunately, the refactoring functionality in Xcode did not work correctly for several years with Swift source files.
The good news is that as of Xcode 9, the refactoring functionaliy in Xcode has not only been restored for Swift files, but it has been re-written from the ground up to work for most of the source code types you would generally work on in Xcode!
Yes, you might be saying, “Enough of the sales pitch, show me how to refactor!” There are a couple of ways to access the refactor functionality, but the easiest is to simply right-click (or, Control-click) on any class name, method, or variable.
You’ll get a menu similar to this:
You should notice two things about the above screenshot:
-
Notice how the class name (or method name, or variable name) that was under your cursor when you right-clicked was highlighted? That indicates that the highlighted name is the one that would be renamed.
-
Notice the Rename… option on the menu under Refactor? It’s this menu option which provides the rename functionality. (There are other refactoring options on that menu too — most of them should be fairly obvious based on the menu title.)
➤ If you right-clicked over the AddItemViewController class name, select the Refactor — Rename… option now. (If you right-clicked elsewhere, first move your cursor over the class name, right-click, and then select Rename…). You should get a screen similar to the following:
The new screen shows you all the files and instances (including the storyboard and file names) in the project where the particular name you selected is used. Also notice how the name at the instance where you right-clicked is now editable.
Start typing in the new name you want and you’ll notice that all the matching names for all the other instances in the view update in real-time. Cool!
When you’ve entered the correct name and verified that everything will be updated correctly, just click the Rename button on the top right corner and you’re done.
Note: While the refactoring works flawlessly most of the time, I’ve sometimes had Xcode do all the refactoring correctly except for renaming the current file itself. If this does happen to you, you might have to rename the file manually by looking for it in the project folder and renaming it via Finder.
Testing the code after a refactor
Let’s see if everything works correctly now.
➤ Press ⌘+B to compile the app.
Note: Getting a “Build Failed” error? Sometimes this does happen after a massive change across the whole project like this. The first thing to try is to use the Xcode menu’s Product ▸ Clean Build Folder option and try building again. It should work in most cases at that point.
Because you made quite a few changes all over the place, it’s a good idea to clean up the debris and detritus from old compiler runs so that Xcode picks up all the new changes. You don’t have to be paranoid about this, but it’s good practice to clean house once in a while.
➤ From Xcode’s menu bar choose Product ▸ Clean Build Folder. When the clean is done, choose Product ▸ Build (or simply press the Run button).
If there are no build issues, run the app again and test the various features just to make sure everything still works! (If the build succeeds but Xcode still shows red error icons in your source file, then close the project and open it again, or restart Xcode. Restarting Xcode is the solution that Almost Always Works™. And if it doesn’t, restarting your computer is the last resort. That does get rid of even the most stubborn issues.)
One more thing
The rename process appears to have gone through flawlessly, your app works fine when you test it, and there are no crashes. So, everything should be fine and you can move on to the next feature in the app, right?
Well… not quite. Switch to ItemDetailViewController.swift and check the protocol definition at the top. What do you see?
Looks as if the protocol name, AddItemViewControllerDelegate, did not change when you renamed AddItemViewController.
If you think about it, it makes sense. AddItemViewControllerDelegate is a different entity than AddItemViewController. So all the renaming did was to change all the references to the AddItemViewController class, not the AddItemViewControllerDelegate protocol.
You can easily change the name of the protocol to ItemDetailViewControllerDelegate by using Xcode’s rename functionality yet again. But you’ll notice that that only changes the protocol name itself — not the protocol method names. Hmm … this is going to be a lot of work!
You can try renaming each protocol method separately and Xcode’s rename functionality will do a stellar job with the renaming, but you’d have to do this three times for the three methods. This could get really time consuming, especially if you were dealing with a protocol with lots of methods. But… there’s an easier way.
What is this easier way? To use Xcode’s search and replace functionality, of course! As you’ll notice, all that remains to change in the ItemDetailViewControllerDelegate is the method names, all of which begin with addItemViewController. So, if you can search for the term addItemViewController across the entire project and replace it with itemDetailViewController, you should be done, right?
Here’s how you do it:
➤ Switch to the Find navigator (fourth tab in the navigator pane).
➤ Click on Find to change it to Replace.
➤ Change Ignoring Case to Matching Case.
➤ Type as the search text: addItemViewController. Important: Make sure you spell it exactly like this since your search term is going to be case-sensitive!
➤ Type in the replacement field: itemDetailViewController, again making sure that you type it exactly.
➤ Press return on your keyboard to start the search. This doesn’t replace anything yet.
The Find navigator shows the files containing matches for the search term. You should see two Swift source files in this list.
➤ Click on any item in the file list above to be taken to that particular match in the relevant file with the match highlighted in the source code:
Have a look through the search results just to make sure Xcode isn’t doing anything you’ll regret later. It should only rename everything that says addItemViewController to itemDetailViewController but there’s always the possibility that Xcode matched some unrelated code accidentally.
➤ If you are satisfied that the matches are correct, click Replace All. (You could also select only some results in the list and then click Replace to have only those results be changed.)
Now Run the app and test its functionality once again to make sure that everything works. If it does, you are done with this particular task, finally!
Iterative development
If you think this approach to development we’ve taken so far is a little messy, then you’re absolutely right. You started out with one design, but as you continued development you found out that things didn’t work out so well in practice, and that you had to refactor your approach a few times to find a way that works. This is actually how software development goes in practice.
You first build a small part of your app and everything looks and works fine. Then you add the next small part on top of that and suddenly everything breaks down. The proper thing to do is to go back and restructure your entire approach so that everything is hunky-dory again… Until the next change you make.
Software development is a constant process of refinement. In this book you’re not given a perfect piece of code and get an explaination of how each part works. That’s not how software development happens in the real world.
Instead, you’re working your way from zero to a full app, exactly the way a pro developer would, including the mistakes and dead ends. (Sure, some processes, like setting up a data model, might have happened in a slightly different order if certain concepts did not have to be explained first — some liberties were taken in order to make some concepts clear. But the basic process of building, tearing down, and re-building remains the same.)
You can find the project files for the app up to this point under 14 - Edit Items in the Source Code folder.