Chapters

Hide chapters

iOS Apprentice

Eighth Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

My Locations

Section 3: 11 chapters
Show chapters Hide chapters

Store Search

Section 4: 12 chapters
Show chapters Hide chapters

18. User Defaults
Written by Eli Ganim

You now have an app that lets you create lists and add to-do items to those lists. All of this data is saved to long-term storage so that even if the app gets terminated, nothing is lost.

There are some improvements — both to the user interface and to the code — that you can make though.

This chapter covers the following:

  • Remember the last open list: Improve the user-experience by remembering the last open list on app re-launch.
  • Defensive programming: Adding in checks to guard against possible crashes — coding defensively instead of reacting to crashes later.
  • The first-run experience: Improving the first-run experience for the user so that the app looks more polished and user-friendly.

Remembering the last open list

Imagine the user is on the Birthdays checklist and switches to another app. The Checklists app is now suspended. It is possible that at some point the app gets terminated and is removed from memory. When the user reopens the app some time later, it no longer is on Birthdays but on the main screen. Because it was terminated, the app didn’t simply resume where it left off, but got launched anew.

You might be able to get away with this, as apps don’t get terminated often (unless your users play a lot of games that eat up memory), but little things like this matter in iOS apps.

Fortunately, it’s fairly easy to remember whether the user had opened a checklist and to switch to it when the app starts up.

Using UserDefaults

You could store this information in the Checklists.plist file, but for simple settings such as this, there is another option — the UserDefaults object.

UserDefaults works like a dictionary, which is a collection object for storing key-value pairs. You’ve already seen the array collection, which stores an ordered list of objects. The dictionary is another very common collection that looks like this:

A dictionary is a collection of key-value pairs
A dictionary is a collection of key-value pairs

Dictionaries in Swift are handled by the Dictionary object (who would’ve guessed?).

You can put objects into the dictionary under a reference key and then retrieve it later using that key. This is, in fact, how Info.plist works.

The Info.plist file is read into a dictionary and then iOS uses the various keys (on the left hand) to obtain the values (on the right hand). Keys are usually strings but values can be any type of object.

To be accurate, UserDefaults isn’t a true dictionary, but it certainly acts like one.

When you insert new values into UserDefaults, they are saved somewhere in your app’s sandbox. So, these values persist even after the app terminates.

You don’t want to store huge amounts of data inside UserDefaults, but it’s ideal for small things like settings — and for remembering what screen the app was on when it closed.

This is what you are going to do:

  1. On the segue from the main screen, AllListsViewController, to the checklist screen, ChecklistViewController, you write the row index of the selected list into UserDefaults. This is how you’ll remember which checklist was active.

    You could have saved the name of the checklist instead of the row index, but what would happen if two checklists have the same name? Unlikely, but not impossible. Using the row index guarantees that you’ll always select the proper one.

  2. When the user presses the back button to return to the main screen, you have to remove this value from UserDefaults again. It is common to set a value such as this to -1 to mean “no value.”

    Why -1? You start counting rows at 0, so you can’t use 0. Positive numbers are also out of the question, unless you use a huge number such as 1000000 as it’s very unlikely the user will make that many checklists. -1 is not a valid row index — and because it’s a negative value it looks weird, making it easy to spot during debugging.

    (If you’re wondering why you’re not using an optional for this — good question! — the answer is that UserDefaults cannot handle optionals. Sad face.)

  3. If the app starts up and the value from UserDefaults isn’t -1, the user was previously viewing the contents of a checklist and you have to manually perform a segue to the ChecklistViewController for the corresponding row.

Phew, it’s more work to explain this in English than writing the actual code.

Let’s start with the segue from the main screen. Recall that this segue is triggered from code rather than from the storyboard.

➤ In AllListsViewController.swift, change tableView(_:didSelectRowAt:) to the following:

override func tableView(_tableView: UITableView, 
          didSelectRowAt indexPath: IndexPath) {
  // add this line:
  UserDefaults.standard.set(indexPath.row, 
                            forKey: "ChecklistIndex")
  . . .
}

In addition to what this method did before, you now store the index of the selected row into UserDefaults under the key “ChecklistIndex.”

Navigation controller delegate

To be notified when the user presses the back button on the navigation bar, you have to become a delegate of the navigation controller. Being the delegate means that the navigation controller tells you when it pushes or pops view controllers on the navigation stack. The logical place for this delegate is the AllListsViewController.

➤ Add the delegate protocol to the class line in AllListsViewController.swift:

class AllListsViewController: UITableViewController, 
                              ListDetailViewControllerDelegate, 
                              UINavigationControllerDelegate {

As you can see, a view controller can be a delegate for many objects at once.

AllListsViewController is now the delegate for both the ListDetailViewController and the UINavigationController, but also implicitly for the UITableView (because it is a table view controller).

➤ Add the following delegate method to AllListsViewController.swift:

// MARK:- Navigation Controller Delegates
func navigationController(
                _ navigationController: UINavigationController, 
               willShow viewController: UIViewController, 
                              animated: Bool) {

  // Was the back button tapped?
  if viewController === self {
    UserDefaults.standard.set(-1, forKey: "ChecklistIndex")
  }
}

This method is called whenever the navigation controller shows a new screen.

If the back button was pressed, the new view controller is AllListsViewController itself and you set the “ChecklistIndex” value in UserDefaults to -1, meaning that no checklist is currently selected.

Equal or identical

To determine whether the AllListsViewController is the newly activated view controller, you wrote:

if viewController === self {

Yep, it’s not a typo, that’s three equals signs in a row.

Previously, to compare objects you used only two equals signs:

if segue.identifier == "AddItem" {

You may be wondering what the difference between these two operators is. It’s a subtle but important question about identity. (Who said programmers couldn’t be philosophical?)

If you use ==, you’re checking whether two variables have the same value.

With === you’re checking whether two variables refer to the exact same object.

Imagine two people who are both called Joe. They’re different people who just happen to have the same name.

If we’d compare them using joe1 === joe2 then the result would be false, as they’re not the same person.

But joe1.name == joe2.name would be true.

On the other hand, if I’m telling you an amusing (or embarrassing!) story about Joe and this story seems awfully familiar to you, then maybe we happen to know the same Joe.

In that case, joe1 === joe2 would be true as well.

By the way, the above code would have worked just fine if you had written,

if viewController == self

with just two equals signs. For objects such as view controllers, equality is tested by comparing the references, just like === would do. But technically speaking, === is more correct here than ==.

Showing the last open list

The only thing that remains is to check at startup which checklist you need to show and then perform the segue to the to-do item list manually. You’ll do that in viewDidAppear().

➤ Add the viewDidAppear() method to AllListsViewController.swift:

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)

  navigationController?.delegate = self

  let index = UserDefaults.standard.integer(
                                     forKey: "ChecklistIndex")
  if index != -1 {
    let checklist = dataModel.lists[index]
    performSegue(withIdentifier: "ShowChecklist", 
                         sender: checklist)
  }
}

UIKit automatically calls this method after the view controller becomes visible.

First, the view controller makes itself the delegate for the navigation controller.

Every view controller has a built-in navigationController property. To access its delegate property you use the notation navigationController?.delegate because the navigation controller is optional.

(You could also have written navigationController! instead of ?. The difference between the two is that ! will crash the app if this view controller was ever to be shown outside of a UINavigationController, while ? won’t crash but simply ignore the rest of that line. For our app, this does not matter.)

Then it checks UserDefaults to see whether it has to perform the segue.

If the value of the “ChecklistIndex” setting is -1, then the user was on the app’s main screen before the app was terminated, and we don’t have to do anything.

However, if the value of the “ChecklistIndex” setting is not -1, then the user was previously viewing a checklist and the app should segue to that screen. As before, you place the relevant Checklist object into the sender parameter of performSegue(withIdentifier:sender:).

The != operator means: not equal. It is the opposite of the == operator. If you’re mathematically-inclined, with some imagination != looks like ≠. (Some languages use <> for not equal but that won’t work in Swift.)

Note: It may not be immediately obvious what’s going on here.

viewDidAppear() isn’t just called when the app starts up but also every time the navigation controller slides the main screen back into view.

Checking whether to restore the checklist screen needs to happen only once when the app starts, so why did you put this logic in viewDidAppear() if it gets called more than once? Here’s the reason:

The very first time AllListsViewController’s screen becomes visible, you don’t want the navigationController(_:willShow:animated:) delegate method to be called yet, as that would always overwrite the old value of “ChecklistIndex” with -1, before you’ve had a chance to restore the old screen.

By waiting to register AllListsViewController as the navigation controller delegate until it is visible, you avoid this problem. viewDidAppear() is the ideal place for that, so it makes sense to do it from that method.

However, as mentioned, viewDidAppear() also gets called after the user presses the back button to return to the All Lists screen. That shouldn’t have any unwanted side effects, such as triggering the segue again.

Naturally, the navigation controller calls navigationController(_:willShow:animated:) when the back button is pressed, but this happens before viewDidAppear(). The delegate method always sets the value of “ChecklistIndex” back to -1, and as a result, viewDidAppear() does not trigger a segue again.

And so it all works out… The logic that you added to viewDidAppear() only does its job once during app startup. There are other ways to solve this particular issue but this approach is simple.

Is all of this going way over your head? Don’t fret about it. To get a better idea of what’s going on, sprinkle print() statements around the various methods to see in which order they get called. Change things around to see what the effect is. Jumping into the code and playing with it is the quickest way to learn!

Double-check that all the lines with UserDefaults use the same key name, “ChecklistIndex.” If one of them is misspelled, UserDefaults is reading from or writing to different items.

➤ Run the app and go to a checklist screen. Exit to the home screen via the Home button, followed by Stop to quit the app.

Tip: You need to exit to the home screen first because UserDefaults may not immediately save its settings to disk, and therefore, you may lose your changes if you kill the app from within Xcode.

Note: Does the app crash for you at this point? That happens if you didn’t add any lists or to-do items yet. That’s the exact problem we’ll solve in the next section. You can either comment out the code in viewDidAppear(), add some to-do items, and enable the code again to try it. Or, simply move on to the next section.

➤ Run the app again and you’ll notice that Xcode immediately switches to the screen where you were last at. Cool, huh?

Defensive programming

➤ Now do the following: stop the app and delete it from the Simulator by holding down on the app icon until it starts to wiggle and then deleting it.

Then, run the app again from within Xcode and watch it crash:

fatal error: Index out of range

The app crashes in viewDidAppear() on the line:

let checklist = dataModel.lists[index]

What’s going on here? Apparently, the value of index is not -1, because the code entered the if statement.

As it turns out index is 0, even though there should be nothing in UserDefaults yet because this is a fresh install of the app. The app didn’t write anything in the “ChecklistIndex” key yet.

Here’s the thing: UserDefaults’s integer(forKey:) method returns 0 if it cannot find the value for the key you specified. But in this app, 0 is a valid row index.

At this point, the app doesn’t have any checklists yet. So, index 0 does not exist in the lists array. That is why the app crashes.

What should happen instead, is that UserDefaults returns -1 if nothing is set yet for “ChecklistIndex,” because to your app -1 means: show the main screen instead of a specific checklist.

Setting a default value for a UserDefaults key

Fortunately, UserDefaults will let you set default values for the default values. Yep, you read that correctly. Let’s do that in the DataModel object.

➤ Add the following method to DataModel.swift:

func registerDefaults() {
  let dictionary = [ "ChecklistIndex": -1 ]
  UserDefaults.standard.register(defaults: dictionary)
}

This creates a new Dictionary instance and adds the value -1 for the key “ChecklistIndex.”

The square bracket notation is not only used to make arrays, but also dictionaries. The difference is that for a dictionary it always looks like,

[ key1: value1, key2: value2, . . . ]

while an array is just:

[ value1, value2, value3, . . . ]

UserDefaults will use the values from this dictionary if you ask it for a key and it cannot find a value for that key.

➤ Change DataModel.swift’s init() to call this new method:

init() {
  loadChecklists()
  registerDefaults()
}

➤ Run the app again. Now, it should no longer crash.

Why did you do this in DataModel? Well, mostly because it’s not a good idea to sprinkle all of these calls to UserDefaults throughout the code — it’s better to centralize functionality where possible.

Cleaning up the code

In fact, let’s move all of the UserDefaults stuff into DataModel.

➤ Add the following to DataModel.swift:

var indexOfSelectedChecklist: Int {
  get {
    return UserDefaults.standard.integer(
                              forKey: "ChecklistIndex")
  }
  set {
    UserDefaults.standard.set(newValue, 
                              forKey: "ChecklistIndex")
  }
}

This does something you haven’t seen before. It appears to declare a new instance variable indexOfSelectedChecklist of type Int, but what are these get { } and set { } blocks?

This is an example of a computed property.

There isn’t any storage allocated for this property — so it’s not really a variable. Instead, when the app tries to read the value of indexOfSelectedChecklist, the code in the get block is performed. And when the app tries to put a new value into indexOfSelectedChecklist, the set block is performed.

From now on, you can simply use indexOfSelectedChecklist and it will automatically update UserDefaults. How cool is that?

You’re doing this so the rest of the code won’t have to worry about UserDefaults anymore. The other objects just have to use the indexOfSelectedChecklist property on DataModel.

Hiding implementation details is an important object-oriented programming principle, and this is one way to do it.

If you decide later that you want to store these settings somewhere else, for example, in a database, or in iCloud, then you only have to change this in one place — in DataModel. The rest of the code will be oblivious to these changes. That’s a good thing.

➤ Update the code in AllListsViewController.swift to use this new computed property:

override func viewDidAppear(_animated: Bool) {
  ...
  let index = dataModel.indexOfSelectedChecklist // change this
  if index != -1 {
  ...
  }
}
override func tableView(_ tableView: UITableView, 
           didSelectRowAt indexPath: IndexPath) {
  // change this line
  dataModel.indexOfSelectedChecklist = indexPath.row  
  ...
}
func navigationController(
             _ navigationController: UINavigationController, 
            willShow viewController: UIViewController, 
                           animated: Bool) {
  if viewController === self {
    dataModel.indexOfSelectedChecklist = -1   // change this
  }
}

The intent of the code is now much clearer. AllListsViewController no longer has to worry about the “how” — storing values in UserDefaults — and can simply focus on the “what” — changing the index of the selected checklist.

➤ Run the app again and make sure everything still works.

A subtle bug

It’s pretty nice that the app now remembers what screen you were on, but this new feature has also introduced a subtle bug in the app. Here’s how to reproduce it:

➤ Start the app and add a new checklist. Also, add a new to-do item to this list. Now kill the app from within Xcode.

Because you did not exit to the home screen first, the new checklist and its item were not saved to Checklists.plist.

However, there is a (small) chance that UserDefaults did save its changes to disk and now thinks this new list is selected. That’s a problem because that list doesn’t exist anymore (it never made it into Checklists.plist).

UserDefaults will save its changes at indeterminate times. So, it could have saved before you terminated the app.

➤ Run the app again and — if you’re (un)lucky? — it will crash with:

fatal error: Index out of range

If you can’t get this error to appear, make the following change to the set block of indexOfSelectedChecklist and try again. This forces UserDefaults to save its changes every time indexOfSelectedChecklist changes:

  set {
    UserDefaults.standard.set(newValue, 
                      forKey: "ChecklistIndex")
    UserDefaults.standard.synchronize()   // Add this
  }

The reason for the crash is that UserDefaults and the contents of Checklists.plist are out-of-sync. UserDefaults thinks the app needs to select a checklist that doesn’t actually exist. Every time you run the app it will now crash. Yikes!

This situation shouldn’t really happen during regular usage. It happened because you used the Xcode Stop button to kill the app before it had a chance to save the plist file.

Under normal circumstances, the user would press the home button. As the app goes into the background, it properly saves both Checklists.plist and UserDefaults and everything is in sync again.

However, the OS can always decide to terminate the app and then this same situation could occur.

Even though there’s only a small chance that this can go wrong in practice, you should really protect the app against this. These are the kinds of bug reports you don’t want to receive because often, you have no idea what the user did to make it happen.

This is where the practice of defensive programming becomes important. Your code should always check for such boundary cases and be able to gracefully handle them even if they are unlikely to occur.

In our case, you can easily fix AllListsViewController’s viewDidAppear() method to deal with this situation.

➤ Change the if statement in viewDidAppear() to:

if index >= 0 && index < dataModel.lists.count {

Instead of just checking for index != -1, you now do a more precise check to determine whether index is valid. It should be between 0 and the number of checklists in the data model. If not, then you simply don’t segue.

This prevents dataModel.lists[index] from asking for an object at an index that doesn’t exist.

You haven’t seen the && operator before. This symbol means “logical and.” It is used as follows:

if something && somethingElse {
  // do stuff
}

This reads: if something is true and something else is also true, then do stuff.

In viewDidAppear() you only perform the segue when index is 0 or greater and also less than the number of checklists, which means it’s only valid if it lies in between those two values.

With this defensive check in place, you’re guaranteed that the app will not try to segue to a checklist that doesn’t exist, even if the data is out-of-sync.

Note: Even though the app remembers what checklist the user was on, it won’t bother to remember whether the user had the Add/Edit Checklist or Add/Edit Item screen open.

These kinds of data input screens are supposed to be temporary. You open them to make a few changes and then close them again. If the app goes to the background and is terminated, then it’s no big deal if the data input screen disappears.

At least, that is true for this app. If you have an app that allows the user to make many complicated edits in an input screen, you may want to persist those changes when the app closes so the user won’t lose all their work in case the app is killed.

In this chapter you used UserDefaults to remember which screen was open, but iOS actually has a dedicated API for this kind of thing, State Preservation and Restoration. You can read more about this on raywenderlich.com/117471/state-restoration-tutorial.

The first-run experience

Let’s use UserDefaults for something else. It would be nice if the first time you ran the app it created a default checklist for you, simply named “List,” and switched over to that list. This enables you to start adding to-do items right away.

That’s how the standard Notes app works too: you can start typing a note right after launching the app for the very first time, but you can also go one level back in the navigation hierarchy to see a list of all notes.

Checking for first run

To implement the above feature, you need to keep track in UserDefaults whether this is the first time the user runs the app. If it is, you create a new Checklist object.

You can perform all of this logic inside DataModel.

It’s a good idea to add a new default setting to the registerDefaults() method. The key for this value is “FirstTime.”

➤ Change the registerDefaults() method in DataModel.swift (don’t miss the comma after the first line of the dictionary):

func registerDefaults() {
  let dictionary = [ "ChecklistIndex": -1, "FirstTime": true ] 
                   as [String : Any]
  UserDefaults.standard.register(defaults: dictionary)
}

The “FirstTime” setting can be a boolean value because it’s either true (this is the first time) or false (this is any other than the first time).

The value of “FirstTime” needs to be true if this is the first launch of the app after a fresh install.

Also, note that there’s now a type cast for dictionary. Why was that added? Try removing the type cast, the as [String: Any] bit, and see what happens. Xcode will throw up an error.

This is because originally, there was one value in the dictionary and it was an Int. But when you introduced the FirstTime key, its corresponding value is a Bool. Now your dictionary has a mixed set of values — an Int and a Bool. So, at this point, the compiler is unsure whether you meant to have a mixed bag of values, or if it was a mistake on your part. So it wants you to explicitly indicate what the dictionary type is, and that’s why you declare it as [String: Any], to indicate that the value could indeed be of any type.

➤ Still in DataModel.swift, add a new handleFirstTime() method:

func handleFirstTime() {
  let userDefaults = UserDefaults.standard
  let firstTime = userDefaults.bool(forKey: "FirstTime")
  
  if firstTime {
    let checklist = Checklist(name: "List")
    lists.append(checklist)
    
    indexOfSelectedChecklist = 0
    userDefaults.set(false, forKey: "FirstTime")
    userDefaults.synchronize()
  }
}

Here you check UserDefaults for the value of the “FirstTime” key. If the value for “FirstTime” is true, then this is the first time the app is being run. In that case, you create a new Checklist object and add it to the array.

You also set indexOfSelectedChecklist to 0, which is the index of this newly added Checklist object, to make sure the app will automatically segue to the new list in AllListsViewController’s viewDidAppear() method.

Finally, you set the value of “FirstTime” to false, so this code won’t be executed again the next time the app starts up.

➤ Call this new method from DataModel’s init():

init() {
  loadChecklists()
  registerDefaults()
  handleFirstTime()    // Add this
}

➤ Remove the app from the simulator and run it again from Xcode.

Because it’s the first time you run the app — at least from the app’s perspective — after a fresh install, it will automatically create a new checklist named List and switch to it.

Organizing source files

At this point, your Project navigator probably lists your files like this (or something similar):

Project navigator file listing
Project navigator file listing

It’s a bit messy since it’s hard to find where a given file is. Sure, you know exactly where each file is now, but what happens when you have 20 or 30 files in there? Or a hundred?

Xcode does provide a few different ways to organize your files.

The first thing you can do is a simple alphabetical sorting of files so that you can find a given file quickly — since it will be in alphabetical order. That is simple enough to accomplish.

➤ Right-click (or Control-click) on the yellow Checklists folder. A context menu should pop up.

Context menu for folder
Context menu for folder

➤ Select Sort by Name.

Voila! All the files inside the Checklists folder are now in alphabetical order.

Sorted file listing
Sorted file listing

That certainly makes finding files a lot easier, but what if you had 20 or 30 files? Or even a hundred? You would still have to do a lot of scrolling around to find the exact file you wanted.

Xcode does provide a filter field at the bottom of the Navigator pane that you can use to filter files in the current list by name. You can type in, for example, “Controller” and it will display only the files with “Controller” in the file name. You can click the little circle icon with an “x” in the filter field to clear the filter.

Filter file list by name
Filter file list by name

But you can do better! You can also organize your files into folders, called groups, so that you can organize the files by functionality. For example, you can put all your view controllers together into a folder called View Controllers, the data models into a Data Models folder and so on… You probably noticed the New Group menu option on the folder context menu when you right-clicked on the Checklists folder earlier. That’s what you need to use in order to create a new group.

Simply create a new group (or three), drag files into the group and you should be set. You could quite easily organize the file listing from above to look something like this:

Organized file listing
Organized file listing

Find the project for the app under 18 - UserDefaults in the Source Code folder.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.