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

20. Local Notifications
Written by Eli Ganim

I hope you’re still with me! We have discussed view controllers, navigation controllers, storyboards, segues, table views and cells, and the data model in great detail. These are all essential topics to master if you want to build iOS apps because almost every app uses these building blocks.

In this chapter you’re going to expand the app to add a new feature: local notifications, using the iOS User Notifications framework. A local notification allows the app to schedule a reminder to the user that will be displayed even when the app is not running.

You will add a “due date” field to the ChecklistItem object and then remind the user about this deadline with a local notification.

If this sounds like fun, then keep reading!

The steps for this chapter are as follows:

  • Try it out: Try out a local notification just to see how it works.
  • Set a due date: Allow the user to pick a due date for to-do items.
  • Due date UI: Create a date picker control.
  • Schedule local notifications: Schedule local notifications for the to-do items, and update them when the user changes the due date.

Trying it out

Before you wonder about how to integrate local notifications with Checklists, let’s just schedule a local notification and see what happens.

By the way, local notifications are different from push notifications (also known as remote notifications). Push notifications allow your app to receive messages about external events, such as your favorite team winning the World Series.

Local notifications are more similar to an alarm clock: you set a specific time and then it “beeps.”

Getting permission to display local notifications

An app is only allowed to show local notifications after it has asked the user for permission. If the user denies permission, then any local notifications for your app simply won’t appear. You only need to ask for permission once, so let’s do that first.

➤ Open AppDelegate.swift and add an new import to the top of the file:

import UserNotifications

This tells Xcode that we’re going to use the User Notifications framework.

➤ Add the following to application(_:didFinishLaunchingWithOptions:), just before the return true line:

// Notification authorization
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { 
  granted, error in
  if granted {
    print("We have permission")
  } else {
    print("Permission denied")
  }
}

Recall that application(_:didFinishLaunchingWithOptions:) is called when the app starts up. It is the entry point for the app, the first place in the code where you can do something after the app launches.

Because you’re just playing with these local notifications now, this is a good place to ask for permission.

You tell iOS that the app wishes to send notifications of type “alert” with a sound effect. Later you’ll put this code into a more appropriate place.

Things that start with a dot

Throughout the app you’ve seen things like .none, .checkmark, and .subtitle — and now .alert and .sound. These are enumeration symbols.

An enumeration, or enum for short, is a data type that consists of a list of possible symbols and their values.

For example, the UNAuthorizationOptions enum contains the symbols (and a few others besides):

.badge
.sound
.alert
.carPlay

You can combine these names in an array to define what sort of notifications the app will show to the user. Here you’ve chosen the combination of an alert and a sound effect by writing [.alert, .sound].

It’s easy to spot when an enum is being used because of the dot in front of the symbol name. This is actually shorthand notation; you could also have written it like this:

center.requestAuthorization(options: 
  [UNAuthorizationOptions.alert, UNAuthorizationOptions.sound]) {
  . . . 

Fortunately, Swift is smart enough to realize that .alert and .sound are from the enum UNAuthorizationOptions, so you can save yourself some keystrokes.

➤ Run the app. You should immediately get a pop-up asking for permission:

The permission dialog
The permission dialog

Tap Allow. The next time you run the app you won’t be asked again; iOS remembers your choice.

If you tapped Don’t Allow — naughty! — then you can always reset the Simulator to get the permissions dialog again. You can also change the notification options via the Settings app.

Showing a test local notification

➤ Stop the app and add the following code to the end of didFinishLaunchingWithOptions (but before the return):

let content = UNMutableNotificationContent()
content.title = "Hello!"
content.body = "I am a local notification"
content.sound = UNNotificationSound.default

let trigger = UNTimeIntervalNotificationTrigger(
                                   timeInterval: 10, 
                                        repeats: false)
let request = UNNotificationRequest(
                         identifier: "MyNotification", 
                            content: content, 
                            trigger: trigger)
center.add(request)

This creates a new local notification. Because you wrote timeInterval: 10, it will fire exactly 10 seconds after the app has started.

The UNMutableNotificationContent describes what the local notification will say. Here, you set an alert message to be shown when the notification fires. You also set a sound.

Finally, you add the notification to the UNUserNotificationCenter. This object is responsible for keeping track of all the local notifications and displaying them when they are up.

➤ Run the app. Immediately after it has started, exit to the home screen.

Wait 10 seconds… It seems like an eternity! After an agonizing 10 seconds a message should pop up:

The local notification message
The local notification message

➤ Tap the notification to go back to the app.

And that’s a local notification. Pretty cool, huh?

Note that iOS will only show a notification alert if the app is not currently active.

➤ Stop the app and run it again. This time don’t press Home and just wait.

Well, don’t wait too long — nothing will happen. The local notification does get fired, but it is not shown to the user. To handle this situation, we must listen somehow to interesting events that concern these notifications. How? Through a delegate, of course!

Handling local notification events

➤ Add the notification delegate to AppDelegate’s class declaration:

class AppDelegate: UIResponder, UIApplicationDelegate, 
                   UNUserNotificationCenterDelegate {

This makes AppDelegate the delegate for the UNUserNotificationCenter.

➤ Also add the following method to AppDelegate.swift:

// MARK:- User Notification Delegates
func userNotificationCenter(
                    _ center: UNUserNotificationCenter, 
    willPresent notification: UNNotification, 
    withCompletionHandler completionHandler: 
    @escaping (UNNotificationPresentationOptions) -> Void) {
  print("Received local notification \(notification)")
}

This method will be invoked when the local notification is posted and the app is still running. You won’t do anything here except log a message to the debug pane.

When your app is active and in the foreground, it is supposed to handle any fired notifications in its own manner. Depending on the type of app, it may make sense to react to the notification, for example to show a message to the user or to refresh the screen.

➤ Finally, tell the UNUserNotificationCenter that AppDelegate is now its delegate. You do this in application(_:didFinishLaunchingWithOptions:) (add this after you ask for permission — perhaps when permission is granted?):

center.delegate = self

➤ Run the app again and just wait (don’t press Home).

After 10 seconds you should see a message in the Xcode Console. It displays something like this:

Received local notification <UNNotification: 0x7ff54af135e0; date: 
2016-07-11 14:21:27 +0000, request: <UNNotificationRequest: . . . 
identifier: MyNotification, content: <UNNotificationContent: . . . 
title: Hello!, subtitle: (null), body: I am a local notification,
. . .

All right, now you know that it works, you should remove the test code from AppDelegate.swift because you don’t really want to schedule a new notification every time the user starts the app.

➤ Remove the the local notification code from didFinishLaunchingWithOptions, but keep these lines:

let center = UNUserNotificationCenter.current()
center.delegate = self

You can also keep the userNotificationCenter(_:willPresent:withCompletionHandler:) method, as it will come in handy when debugging the local notifications.

Setting a due date

Let’s think about how the app will handle these notifications. Each ChecklistItem will get a due date field (a Date object, which stores a date and time) and a Bool that says whether the user wants to be reminded of this item or not.

Users might not want to be reminded of everything, so you shouldn’t schedule local notifications unless the user asks for it. Such a Bool variable is often called a flag. Let’s name it shouldRemind.

When do you schedule a notification?

First, let’s figure out how and when to schedule the notifications. Here are some situations:

  • When the user adds a new ChecklistItem object that has the shouldRemind flag set, you must schedule a new notification.

  • When the user changes the due date on an existing ChecklistItem, the old notification (if there is one) should be cancelled and a new one scheduled in its place (if shouldRemind is still set).

  • When the user toggles the shouldRemind flag from on to off, the existing notification should be cancelled. The other way around, from off to on, should schedule a new notification.

  • When the user deletes a ChecklistItem, its notification, if it had one, should be cancelled.

  • When the user deletes an entire Checklist, all the notifications for those items, if there are any, should be cancelled.

This makes it obvious that you don’t need just a way to schedule new notifications, but also a way to cancel them.

You should probably also check that you don’t create notifications for to-do items whose due dates are in the past. I’m sure iOS is smart enough to ignore those notifications, but let’s be good citizens anyway.

Associating to-do items with notifications

We need some way to associate ChecklistItem objects with their local notifications. This requires some changes to our data model.

When you schedule a local notification, you create a UNNotificationRequest object. It is tempting to put the UNNotificationRequest object as an instance variable in ChecklistItem, so you always know what it is. However, this is not the correct approach.

Instead, you’ll use an identifier. When you create a local notification, you need to give it an identifier, which is just a String. It doesn’t really matter what is in this string, as long as it is unique for each notification.

To cancel a notification at a later point, you don’t use the UNNotificationRequest object but the identifier you gave it. The right approach is to store this identifier in the ChecklistItem object.

Even though the identifier for the local notification is a String, you’ll give give each ChecklistItem an identifier that is simply a number. You’ll also save this item ID in the Checklists.plist file. When it’s time to schedule or cancel a local notification, you’ll turn that number into a string. Then, you can easily find the notification when you have the ChecklistItem object, or the ChecklistItem object when you have the notification object.

Assigning numeric IDs to objects is a common approach when creating data models — it is very similar to giving records in a relational database a numeric primary key, if you’re familiar with that sort of thing.

➤ Add these properties to ChecklistItem.swift:

var dueDate = Date()
var shouldRemind = false
var itemID = -1

Note that you called the last variable itemID and not simply “id.” The reason is that id is a special keyword in Objective-C, and this could cause trouble if you ever wanted to mix your Swift code with Objective-C code.

In the above code, you have initialized itemID with -1. However, what you really want is to have itemID set to a unique integer value when a new ChecklistItem instance is created. You can do this by adding a custom initailizer to ChecklistItem.

But before you do that, you need to add a new method to DataModel to generate a unique item ID.

➤ Hop on over to DataModel.swift and add a new method:

class func nextChecklistItemID() -> Int {
  let userDefaults = UserDefaults.standard
  let itemID = userDefaults.integer(forKey: "ChecklistItemID")
  userDefaults.set(itemID + 1, forKey: "ChecklistItemID")
  userDefaults.synchronize()
  return itemID
}

You’re using your old friend UserDefaults again.

This method gets the current “ChecklistItemID” value from UserDefaults, adds 1 to it, and writes it back to UserDefaults. It returns the previous value to the caller.

The method also does userDefaults.synchronize() to force UserDefaults to write these changes to disk immediately — that way, they won’t get lost if you kill the app from Xcode before it had a chance to save, or the app crashed for some reason.

This is important because you never want two or more ChecklistItems to get the same ID.

You could add a default value for “ChecklistItemID” to the registerDefaults() method so as to customize the start value for the item ID, but you really don’t have to in this case. Remember that if there is no existing value for “ChecklistItemID,” you’d get 0 back from a call to UserDefaults (if you didn’t provide a defualt value via registerDefaults()).

That is good enough for your use since your IDs would then start at 0 and count up.

The first time nextChecklistItemID() is called, it will return the ID 0. The second time it is called, it will return the ID 1, the third time it will return the ID 2, and so on. The number is incremented by one each time. You can call this method a few billion times before you run out of unique IDs.

Class methods vs. instance methods

If you are wondering why you wrote,

class func nextChecklistItemID()

and not just:

func nextChecklistItemID()

then I’m glad you’re paying attention. :-)

Adding the class keyword means that you can call this method without having a reference to an instance of the DataModel object.

With a class method, you do:

itemID = DataModel.nextChecklistItemID()

Instead of:

itemID = dataModel.nextChecklistItemID()

This is because ChecklistItem objects do not have a dataModel property with a reference to a DataModel object. You could certainly pass them such a reference, but using a class method was easier.

The declaration of a class method begins with class func. This kind of method applies to the class as a whole.

So far you’ve been using instance methods. They just have the word func — without class — and work only on a specific instance of that class.

We haven’t discussed the difference between classes and instances before, and you’ll get into that in more detail later in the book. For now, just remember that a method starting with class func allows you to call methods on an object even when you don’t have a reference to that object.

We had to make a trade-off: is it worth giving each ChecklistItem object a reference to the DataModel object, or can you get away with a simple class method? To keep things simple, the latter was chosen. It’s certainly possible that, if you were to develop this app further, it would make more sense to give ChecklistItem a dataModel property instead. But that would be up to you!

➤ Now, switch back to ChecklistItem.swift and add an init() method to set up the unique ID:

override init() {  
  super.init()
  itemID = DataModel.nextChecklistItemID()
}

This asks the DataModel object for a new item ID whenever the app creates a new ChecklistItem object and replaces the initial value of -1 with that unique ID.

Displaying the new IDs

For a quick test to see if assigning these IDs works, you can add them to the text that’s shown in the ChecklistItem cell label — this is just a temporary thing for testing purposes, as users couldn’t care less about the internal identifier of these objects.

➤ In ChecklistViewController.swift, change the configureText(for:with:) method to:

func configureText(for cell: UITableViewCell,
                  with item: ChecklistItem) {
  let label = cell.viewWithTag(1000) as! UILabel
  //label.text = item.text
  label.text = "\(item.itemID): \(item.text)"  
}

I have commented out the original line because you’ll want to reuse it later. The new one uses \( … ) to add the to-do item’s itemID property to the text.

Before you run the app, do note that you have changed the format of the ChecklistItem (and thus, by extension the Checklists.plist file) and so your existing data will not display when you run the app.

➤ Run the app and add some checklist items. Each new item should get a unique identifier. Exit to the home screen (to make sure everything is saved properly) and stop the app.

Run the app again and add some new items; the IDs for these new items should start counting at where the numbering previously left off.

The items with their IDs. Note that the item with ID 3 was deleted in this example.
The items with their IDs. Note that the item with ID 3 was deleted in this example.

OK, that takes care of the IDs. Now lets add the “due date” and “should remind” fields to the Add/Edit Item screen.

Keep configureText(for:with:) the way it is for the time being; that will come in handy with testing the notifications.

Due date UI

You will add settings for the two new fields to the Add/Edit Item screen and make it look like this:

The Add/Edit Item screen now has Remind Me and Due Date fields
The Add/Edit Item screen now has Remind Me and Due Date fields

The Due Date field will require some sort of date picker control. iOS comes with a cool date picker view that you’ll add to the table view.

The UI changes

➤ Add the following outlets to ItemDetailViewController.swift:

@IBOutlet weak var shouldRemindSwitch: UISwitch!
@IBOutlet weak var dueDateLabel: UILabel!

➤ Open the storyboard and select the Table View in the Add Item scene.

➤ Add a new section to the table. The easiest way to do this is to increment the Sections field in the Attributes inspector. This duplicates the existing section and cell.

➤ Remove the Text Field from the new cell. Select the new section in the Document Outline and then increment its Rows value to 2 in the Attributes inspector.

You will now design the new cells to look as follows:

The new design of the Add/Edit Item screen
The new design of the Add/Edit Item screen

➤ Add a Label to the first cell and set its text to Remind Me. Set the font to System, size 17.

➤ Also drag a Switch control into the cell. Hook it up to the shouldRemindSwitch outlet on the view controller. In the Attributes inspector, set its State to Off so it is no longer green.

➤ Pin the Switch to the top, right, and bottom edges of the table view cell. This makes sure the control will be visible regardless of the width of the device’s screen.

Center the Label with respect to the Switch, and then pin its left and right edges.

➤ The third cell has two labels: Due Date on the left and the label that will hold the actual chosen date on the right. You don’t have to add these labels yourself: simply set the Style of the cell to Right Detail and rename Title to Due Date.

➤ The label on the right should be hooked up to the dueDateLabel outlet.

You may need to adjust the Auto Layout constraints for the Remind Me label and the switch to align them nicely with the labels from the “due date” cell. Tip: select the “Due Date” and “Detail” labels and look in the Size inspector what their margins are (should be 16 points from the edges).

Displaying the due date

Let’s write the code for dispalying the due date.

➤ Add a new dueDate instance variable to ItemDetailViewController.swift:

var dueDate = Date()

For a new ChecklistItem item, the due date is right now, i.e. Date(). That sounds reasonable, but by the time the user has filled in the rest of the fields and pressed Done, that due date will be in the past.

But you do have to suggest something here. An alternative default value could be this time tomorrow, or ten minutes from now, but in most cases the user will have to pick their own due date anyway.

➤ Add a new updateDueDateLabel() method to the file:

// MARK:- Helper Methods
func updateDueDateLabel() {
  let formatter = DateFormatter()
  formatter.dateStyle = .medium
  formatter.timeStyle = .short
  dueDateLabel.text = formatter.string(from: dueDate)
}

To convert the Date value to text, you use a DateFormatter object.

The way it works is very straightforward: you give the date formatter a style for the date component and a separate style for the time component, and then ask it to format the Date object.

You can play with different styles here, but space in the label is limited. So, you can’t fit in the full month name, for example.

The cool thing about DateFormatter is that it takes the current locale into consideration — so, the time will look good to the user no matter where they are on the globe.

➤ Change viewDidLoad() as follows:

override func viewDidLoad() {
  . . .
  if let item = itemToEdit {                     
    . . .
    shouldRemindSwitch.isOn = item.shouldRemind  // add this
    dueDate = item.dueDate                       // add this
  }

  updateDueDateLabel()                           // add this
}

If there is an existing ChecklistItem object, you set the switch control to on or off, depending on the value of the object’s shouldRemind property. If the user is adding a new item, the switch is initially off (you did that in the storyboard).

You also get the due date from the ChecklistItem.

Updating edited values

➤ The last thing to change in this file is the done() action. Replace the current code with:

@IBAction func done() {
  if let item = itemToEdit {
    item.text = textField.text!
    
    item.shouldRemind = shouldRemindSwitch.isOn  // add this
    item.dueDate = dueDate                       // add this
    
    delegate?.itemDetailViewController(self, 
                     didFinishEditing: item)
  } else {
    let item = ChecklistItem()
    item.text = textField.text!
    item.checked = false

    item.shouldRemind = shouldRemindSwitch.isOn  // add this
    item.dueDate = dueDate                       // add this
    
    delegate?.itemDetailViewController(self, 
                      didFinishAdding: item)
  }
}

Here, you put the value of the switch control and the dueDate instance variable back into the ChecklistItem object when the user presses the Done button.

➤ Run the app and change the position of the switch control. The app will remember this setting when you terminate it (but be sure to exit to the home screen first).

The due date row doesn’t really do anything yet, however. In order to make that work, you first have to create a date picker.

Note: Maybe you’re wondering why you’re using an instance variable for the dueDate but not for shouldRemind.

You don’t need one for shouldRemind because it’s easy to get the state of the switch control: you just look at its isOn property, which is either true or false.

However, it is hard to read the chosen date back out of the dueDateLabel because the label stores text (a String), not a Date. So it’s easier to keep track of the chosen date separately in a Date instance variable.

The date picker

You will not create a new view controller for the date picker. Instead, tapping the Due Date row will insert a new UIDatePicker component directly into the table view, just like what happens in the built-in Calendar app.

The date picker in the Add Item screen
The date picker in the Add Item screen

➤ Add a new instance variable to ItemDetailViewController.swift, to keep track of whether the date picker is currently visible:

var datePickerVisible = false

➤ Add the showDatePicker() method:

func showDatePicker() {
  datePickerVisible = true
  let indexPathDatePicker = IndexPath(row: 2, section: 1)
  tableView.insertRows(at: [indexPathDatePicker], with: .fade)
}

This sets the new instance variable to true, and tells the table view to insert a new row below the Due Date cell. This new row will contain the UIDatePicker.

The question is: where does the cell for this new date picker row come from? You can’t put it into the table view as a static cell already because then it would always be visible. You only want to show it after the user taps the Due Date row.

Xcode has a feature where you can add additional views to a scene that are not immediately visible. That’s a great solution to this problem!

➤ Open the storyboard and go to the Add Item scene. From the Objects Library, pick Table View Cell. Don’t drag it on to the view controller itself, but instead, on to the scene dock at the top:

Dragging a table view cell into the scene dock
Dragging a table view cell into the scene dock

Now, the storyboard should look like this:

The new table view cell sits in its own area
The new table view cell sits in its own area

The new Table View Cell object belongs to the scene but it is not (yet) part of the scene’s table view.

The cell is a bit too small to fit a date picker, so first you’ll make it bigger.

➤ Select the Table View Cell and in the Size inspector set the Height to 217 — the date picker is 216 points tall, plus one point for the separator line at the bottom of the cell.

➤ In the Attributes inspector, set Selection to None so this cell won’t turn gray when you tap on it.

➤ From the Objects Library, drag a Date Picker into the cell. It should fit exactly.

➤ Use the Add New Constraints menu to glue the Date Picker to the four sides of the cell. Turn off Constrain to margins and then select the four I-beams to make them red (they all should be 0).

When you’re done, the new cell looks like this:

The finished date picker cell
The finished date picker cell

So how do you get this cell into the table view? First, make two new outlets and connect them to the cell and the date picker, respectively. That way you can refer to these views from code.

➤ Add these lines to ItemDetailViewController.swift:

@IBOutlet weak var datePickerCell: UITableViewCell!
@IBOutlet weak var datePicker: UIDatePicker!

➤ Switch back to the storyboard and simply Control-drag from the yellow circle icon for the view controller to the gray icon for the Table View Cell, and select the datePickerCell outlet.

Control-drag between the icons in the scene dock
Control-drag between the icons in the scene dock

➤ To connect the date picker, Control-drag from the yellow icon to the big Date Picker above it and select the datePicker outlet.

Displaying the date picker

Great! Now that you have outlets for the cell and the date picker inside it, you can write the code to add them to the table view.

Normally, you would implement the tableView(_:cellForRowAt:) method, but remember that this screen uses a table view with static cells. Such a table view does not normally use cellForRowAt.

If you look in ItemDetailViewController.swift you won’t find that method anywhere. However, with a bit of trickery you can override the data source for a static table view and provide your own methods.

➤ Add the tableView(_:cellForRowAt:) method to ItemDetailViewController.swift:

override func tableView(_ tableView: UITableView,
             cellForRowAt indexPath: IndexPath) 
             -> UITableViewCell {
  if indexPath.section == 1 && indexPath.row == 2 {
    return datePickerCell
  } else {
    return super.tableView(tableView, cellForRowAt: indexPath)
  }
}

Danger: You shouldn’t really mess around too much with this method when it’s being used by a static table view — it may interfere with the inner workings of those static cells. But if you’re careful you can get away with it.

The if statement checks whether cellForRowAt is being called with the index-path for the date picker row. If so, it returns the new datePickerCell that you just designed. This is safe to do because the table view from the storyboard doesn’t know anything about row 2 in section 1, so you’re not interfering with an existing static cell.

For any index-paths that are not the date picker cell, this method will call through to super (which is UITableViewController). This is the trick that makes sure the other static cells still work.

➤ You also need to override tableView(_:numberOfRowsInSection:):

override func tableView(_ tableView: UITableView, 
      numberOfRowsInSection section: Int) -> Int {
  if section == 1 && datePickerVisible {
    return 3
  } else {
    return super.tableView(tableView, 
      numberOfRowsInSection: section)
  }
}

If the date picker is visible, then section 1 has three rows. If the date picker isn’t visible, you can simply pass through to the original data source.

➤ Likewise, you also need to provide the tableView(_:heightForRowAt:) method:

override func tableView(_ tableView: UITableView,
           heightForRowAt indexPath: IndexPath) -> CGFloat {
  if indexPath.section == 1 && indexPath.row == 2 {
    return 217
  } else {
    return super.tableView(tableView, heightForRowAt: indexPath)
  }
}

So far the cells in your table views all had the same automatic height, but this is not a hard requirement. By providing the heightForRowAt method you can give each cell its own height. The UIDatePicker component is 216 points tall, plus 1 point for the separator line, making for a total row height of 217 points.

Also, while you might think that you can leave the cell sizing for the date picker cell to be worked out by automatic sizing as well, you might find that this doesn’t work so in practice. Try leaving out this method override and see what happens — the app will crash. Unfortunately, trying to override the static table view cell behaviour can lead to all sorts of complications.

The date picker is only made visible after the user taps the Due Date cell, which happens in tableView(_:didSelectRowAt:).

➤ Add that method:

override func tableView(_ tableView: UITableView, 
           didSelectRowAt indexPath: IndexPath) {
  tableView.deselectRow(at: indexPath, animated: true)
  textField.resignFirstResponder()
  if indexPath.section == 1 && indexPath.row == 1 {
    showDatePicker()
  }
}

This calls showDatePicker() when the index-path indicates that the Due Date row was tapped. It also hides the on-screen keyboard if that was visible.

Making the Due Date row tappable

At this point you have most of the pieces in place, but the Due Date row isn’t actually tappable yet. That’s because ItemDetailViewController.swift already has a willSelectRowAt method that always returns nil, causing taps on all rows to be ignored.

➤ Change tableView(_:willSelectRowAt:) to:

override func tableView(_ tableView: UITableView, 
          willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  if indexPath.section == 1 && indexPath.row == 1 {
    return indexPath
  } else {
    return nil
  }
}

Now the Due Date row responds to taps, but the other rows don’t.

➤ Run the app to try it out. Add a new checklist item and tap the Due Date row.

Oops! The app crashes. When you override the data source for a static table view cell, you also need to provide the delegate method tableView(_:indentationLevelForRowAt:).

That’s not a method you’d typically use, but because you’re messing with the data source for a static table view, you do need to override it.

➤ Add the new delegate method:

override func tableView(_ tableView: UITableView, 
  indentationLevelForRowAt indexPath: IndexPath) -> Int {
  var newIndexPath = indexPath
  if indexPath.section == 1 && indexPath.row == 2 {
    newIndexPath = IndexPath(row: 0, section: indexPath.section)
  }
  return super.tableView(tableView, 
          indentationLevelForRowAt: newIndexPath)
}

The reason the app crashed on this method was that the standard data source doesn’t know anything about the cell at row 2 in section 1 (the one with the date picker), because that cell isn’t part of the table view’s design in the storyboard.

So after inserting the new date picker cell, the data source gets confused and it crashes the app. To fix this, you have to trick the data source into believing there really are three rows in that section when the date picker is visible.

➤ Run the app again. This time the date picker cell shows up where it should:

The date picker appears in a new cell
The date picker appears in a new cell

Listening for date picker events

Interacting with the date picker should change the date in the Due Date row, but currently this has no effect whatsover on the Due Date row (try it out: spin the wheels).

You have to listen to the date picker’s “Value Changed” event. That event gets sent whenever the picker wheels settle on a new value. For that, you need to add a new action method.

➤ Add the action method to ItemDetailViewController.swift:

@IBAction func dateChanged(_ datePicker: UIDatePicker) {
  dueDate = datePicker.date
  updateDueDateLabel()
}

This is pretty simple. It updates the dueDate instance variable with the new date and then updates the text on the Due Date label.

➤ In the storyboard, Control-drag from the Date Picker to the view controller and select the dateChanged: action method. Now everything is properly hooked up. (You can verify that the action method is indeed connected to the date picker’s Value Changed event by looking at the Connections inspector.)

➤ Run the app to try it out. When you turn the wheels on the date picker, the text in the Due Date row updates too. Cool.

However, when you edit an existing to-do item, the date picker does not show the date from that item. It always starts on the current date and time.

➤ Add the following line to the bottom of showDatePicker():

datePicker.setDate(dueDate, animated: false)

This passes the proper date to the UIDatePicker component.

➤ Verify that it works: click on the button from an existing to-do item, preferably one you made a while ago, and confirm that the date picker shows the same date and time as the Due Date label. Excellent!

Changing the date label color when the date picker is active

Speaking of the label, it would be nice if this becomes highlighted when the date picker is active. You can use the tint color for this (that’s also what the Calendar app does).

➤ Add the following line to the end of showDatePicker():

dueDateLabel.textColor = dueDateLabel.tintColor

This sets the textColor of the dueDateLabel to the tint color.

Of course, you can only change the colour of a control in a table view cell directly like this because we are dealing with static table view cells. If this were a table where the cells were dynamically generated, then you’d have to first find the correct row, get a reference to the lable withn the cell and then set the text color — so, it would be a slighlty more complicated process.

➤ Run the app. The date now appears in blue:

The date label appears in the tint color while the date picker is visible
The date label appears in the tint color while the date picker is visible

Hiding the date picker

When the user taps the Due Date row again, the date picker should disappear. If you try that right now the app will crash — what did you expect? This won’t win it many favorable reviews.

➤ Add a new hideDatePicker() method:

func hideDatePicker() {
  if datePickerVisible {
    datePickerVisible = false
    let indexPathDatePicker = IndexPath(row: 2, section: 1)
    tableView.deleteRows(at: [indexPathDatePicker], with: .fade)
    dueDateLabel.textColor = UIColor.black
  }
}

This does the opposite of showDatePicker(). It deletes the date picker cell from the table view and restores the color of the date label to the original color.

➤ Change tableView(_:didSelectRowAt:) to toggle between the visible and hidden states.

override func tableView(_ tableView: UITableView, 
           didSelectRowAt indexPath: IndexPath) {
  . . .
  if indexPath.section == 1 && indexPath.row == 1 {
    if !datePickerVisible {
      showDatePicker()
    } else {
      hideDatePicker()
    }
  }
}

There is another scenario where it’s a good idea to hide the date picker — when the user taps inside the text field. It won’t look very nice if the keyboard partially overlaps the date picker, so you might as well hide it. The view controller is already the delegate for the text field, making this easy.

➤ Add the textFieldDidBeginEditing(_:) method:

func textFieldDidBeginEditing(_ textField: UITextField) {
  hideDatePicker()
}

And with that you have a cool inline date picker!

➤ Run the app and verify that hiding the date picker works for both scenarios.

Scheduling local notifications

One of the principles of object-oriented programming is that objects should do as much as possible themselves. Therefore, it makes sense that the ChecklistItem object should schedule its own notifications.

Scheduling notifications

➤ Add the following method to ChecklistItem.swift:

func scheduleNotification() {
  if shouldRemind && dueDate > Date() {
    print("We should schedule a notification!")
  }
}

This compares the due date on the item with the current date — you can always get the current date (and time) by making a new Date object.

The statement dueDate > Date() compares the two dates and returns true if dueDate is in the future and false if it is in the past.

If the due date is in the past, the print() will not be performed.

Note the use of the “and” (&&) operator. You only print the text when the Remind Me switch is set to “on” and the due date is in the future.

You will call this method when the user presses the Done button after adding or editing a to-do item.

➤ In the done() action in ItemDetailViewController.swift, add the following line just before the call to didFinishEditing and also before didFinishaAdding:

item.scheduleNotification()

➤ Run the app and try it out. Add a new item, set the switch to ON but don’t change the due date. Press Done.

There should be no message in the Console because the due date has already passed (it is several seconds in the past by the time you press Done).

➤ Add another item, set the switch to ON, and choose a due date in the future.

When you press Done now, the text “We should schedule a notification!” should appear in the Console.

Now that you’ve verified the method is called in the proper place, let’s actually schedule a new local notification object for the following three scenarios: adding a to-do item, editing a to-to item, deleting a to-do item.

Adding a to-do item

➤ In ChecklistItem.swift, change scheduleNotification() to:

func scheduleNotification() {
  if shouldRemind && dueDate > Date() {
    // 1
    let content = UNMutableNotificationContent()
    content.title = "Reminder:"
    content.body = text
    content.sound = UNNotificationSound.default

    // 2
    let calendar = Calendar(identifier: .gregorian)
    let components = calendar.dateComponents(
                          [.year, .month, .day, .hour, .minute], 
                          from: dueDate)
    // 3
    let trigger = UNCalendarNotificationTrigger(
                                    dateMatching: components, 
                                         repeats: false)
    // 4
    let request = UNNotificationRequest(
            identifier: "\(itemID)", content: content, 
               trigger: trigger)
    // 5
    let center = UNUserNotificationCenter.current()
    center.add(request)

    print("Scheduled: \(request) for itemID: \(itemID)")
  }
}

You’ve seen this code before when you tried out local notifications for the first time, but there are a few differences.

  1. Put the item’s text into the notification message.

  2. Extract the year, month, day, hour, and minute from the dueDate. We don’t care about the number of seconds — the notification doesn’t need to be scheduled with millisecond precision, on the minute is precise enough.

  3. To test local notifications you used a UNTimeIntervalNotificationTrigger, which scheduled the notification to appear after a number of seconds. Here, you’re using a UNCalendarNotificationTrigger, which shows the notification at the specified date.

  4. Create the UNNotificationRequest object. Important here is that we convert the item’s numeric ID into a String and use it to identify the notification. That is how you’ll be able to find this notification later in case you need to cancel it.

  5. Add the new notification to the UNUserNotificationCenter.

Xcode is not so impressed with this new code and gives a bunch of error messages.

What is wrong here? UNUserNotificationCenter and the other objects are provided by the User Notifications framework — you can tell by the “UN” prefix in their names.

However, ChecklistItem hasn’t used any code from that framework until now. The only framework objects it has used, NSObject and Codable, came from another framework, Foundation.

➤ To tell ChecklistItem about the User Notifications framework, you need to add the following line to the top of the file, below the other import:

import UserNotifications

Now the errors disappear like snow in the sun.

There’s another small problem, though. If you’ve reset the Simulator recently, then the app no longer has permission to send local notifications.

➤ Try it out. Run the app, add a new checklist item, set the due date a minute into the future, and press Done. You might not see a notification.

Even if you do see a notification, since the authorization request code is no longer there, Checklists certainly won’t have permission on your user devices.

When you were just messing around at the beginning of this chapter, you placed the permission request code in the AppDelegate and ran it immediately upon launch. That’s not recommended.

Don’t you just hate those apps that prompt you for ten different things before you’ve even had a chance to properly look at them? Let’s be a bit more user friendly with our own app!

➤ Add the following method to ItemDetailViewController.swift:

@IBAction func shouldRemindToggled(_ switchControl: UISwitch) {
  textField.resignFirstResponder()

  if switchControl.isOn {
    let center = UNUserNotificationCenter.current()
    center.requestAuthorization(options: [.alert, .sound]) { 
      granted, error in 
      // do nothing
    }  
  }
}

When the switch is toggled to ON, this prompts the user for permission to send local notifications. Once the user has given permission, the app won’t put up a prompt again.

➤ Also add an import UserNotifications or the above method won’t compile.

➤ Open the storyboard and connect the shouldRemindToggled: action to the switch control.

➤ Test it out. Run the app, add a new checklist item, set the due date a minute into the future, press Done and exit to the home screen.

Wait one minute (patience…) and the notification should appear. Pretty cool!

The local notification when the app is in the background
The local notification when the app is in the background

That takes care of the adding a new item scenario. There are two others left.

Editing an existing item

When the user edits an item, the following situations can occur with the Remind Me switch:

  • Remind Me was switched off and is now switched on. You have to schedule a new notification.
  • Remind Me was switched on and is now switched off. You have to cancel the existing notification.
  • Remind Me stays switched on but the due date changes. You have to cancel the existing notification and schedule a new one.
  • Remind Me stays switched on but the due date doesn’t change. You don’t have to do anything.
  • Remind Me stays switched off. Here you also don’t have to do anything.

Of course, in all those situations you’ll only schedule the notification if the due date is in the future.

Phew, that’s quite a list. It’s always a good idea to take stock of all possible scenarios before you start programming because this gives you a clear picture of everything you need to tackle.

It may seem like you need to write a lot of logic here to deal with all these situations, but actually it turns out to be quite simple.

First you’ll check if there is an existing notification for this to-do item. If there is, you simply cancel it. Then you determine whether the item should have a notification and if so, you schedule a new one.

That should take care of all the above situations, even if sometimes you simply could have left the existing notification alone. The algorithm is crude, but effective.

➤ Add the following method to ChecklistItem.swift:

func removeNotification() {
  let center = UNUserNotificationCenter.current()
  center.removePendingNotificationRequests(
                           withIdentifiers: ["\(itemID)"])
}

This removes the local notification for this ChecklistItem, if it exists. Note that removePendingNotificationRequests() requires an array of identifiers, so we first put our itemID into a string with \(…) and then into an array using [].

➤ Call this new method from to the top of scheduleNotification():

func scheduleNotification() {
  removeNotification()
  . . .
}

Let’s try it out.

➤ Run the app and add a to-do item with a due date two minutes into the future. A new notification will be scheduled. Go to the home screen and wait until it shows up.

➤ Edit the item and change the due date to three minutes into the future. The old notification will be removed and a new one scheduled for the new time.

➤ Add a new to-do item with a due date two minutes into the future. Edit the to-do item but now set the switch to OFF. The old notification will be removed and no new notification will be scheduled.

➤ Edit again and put the time a few minutes into the future but don’t change anything else; no new notification will be scheduled because the switch is still off.

These tests should also work if you terminate the app in between.

Deleting a to-do item

There is one last case to handle: deletion of a ChecklistItem. This can happen in two ways:

  1. The user can delete an individual item using swipe-to-delete.
  2. The user can delete an entire checklist, in which case all its ChecklistItem objects are also deleted.

An object is notified when it is about to be deleted using the deinit message. You can simply implement this method, check if there is a scheduled notification for this item, and then cancel it.

➤ Add the following to ChecklistItem.swift:

deinit {
  removeNotification()
}

That’s all you have to do. The special deinit method will be invoked when you delete an individual ChecklistItem but also when you delete a whole Checklist — because all its ChecklistItems will be destroyed as well, as the array they are in is deallocated.

➤ Run the app and try it out. First, schedule some notifications a minute or so into the future and then remove that to-do item or its entire checklist. Wait until the due date comes and you shouldn’t get a notification.

Once you’re convinced everything works, you can remove the print() statements.

They are only temporary for debugging purposes. You probably don’t want to leave them in the final app. The print() statements won’t hurt, but the end user can’t see those messages anyway.

➤ Also remove the item ID from the label in the ChecklistViewController — that was only used for debugging.

That’s a wrap!

Things should be starting to make sense by now.

I’ve thrown you into the deep end by writing an entire app from scratch. We’ve touched on a number of advanced topics already, but hopefully you were able to follow along quite well with what we’ve been doing. Kudos for sticking with it until the end!

It’s OK if you’re still a bit fuzzy on the details. Sleep on it for a bit and keep tinkering with the code.

Programming requires its own way of thinking and you won’t learn that overnight. Don’t be afraid to do this app again from the start — it will make more sense the second time around!

This section focused mainly on UIKit and its most important controls and patterns. In the next section we’ll take a few steps back to talk more about the Swift language itself. And of course, you’ll build another cool app.

Here is the final storyboard for Checklists:

The final storyboard
The final storyboard

Completing all of that is pretty impressive! Give yourself a well-deserved pat on the back. Take a break, and when you’re ready, continue on to the next section, where you’ll make an app that knows its place!

Haven’t had enough yet? Here are some challenges to sink your teeth into:

Exercise: Display the due date in the table view cells, under the text of the to-do item.

Exercise: Sort the to-do items list based on the due date. This is similar to what you did with the list of Checklists except that now you’re sorting ChecklistItem objects and you’ll be comparing Date objects instead of strings.

You can find the final project files for the Checklists app under 20 - Local Notifications 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.