• 20 hours
  • Medium

Free online content available in this course.

course.header.alt.is_video

course.header.alt.is_certifying

Got it!

Last updated on 4/24/18

Engage users with local notifications

Log in or subscribe for free to enjoy all this course has to offer!

Our app can be of a great help to stay focused and completing tasks. But, if the user forgets to check back and closes the app, or if the app gets terminated by operating system, all the tasks will be lost:(.

Imagine, you spoke with other participants of the project, and decided that it would be beneficial to the user if the app could present reminders!

Discovering UI Notifications

Reminders can be implemented using notifications. This time - UI Notifications. UI Notifications are presented to the user, while they are not using the app.

There are two types of notifications:

  • Local

  • Remote

Local notifications originate and are delivered on the same device. Like an event reminder from a calendar app, for example.

Remote notifications are also called Push notifications, that come from a server. Like a notification upon receiving an email or a text message.

Push Notifications can also be silent. Those will have no UI presence and are meant to just deliver some information for the app.

We'll adopt Local Notifications in our project to implement task reminders, so, let's GO :pirate:!

Implementing Local Notifications

In order to implement local notifications we need to accomplish the following:

  • Request permission

  • Schedule Notifications

While working on this matter, we'll have a chance to work with the file we haven't touched yet -  AppDelegate.swift . As you may guess my it's name, it's an application delegate and the delegating 'object' is iOS itself. When iOS needs to collaborate with our application, this is the object that's a gatekeeper!

To be able to use user notifications functionality, import UserNotification framework in AppDelegate.swift:

import UserNotifications

Request Permission

Notifications are a delicate matter. Perhaps you experienced it yourself when an app on your device keeps sending annoying notifications or, perhaps at a wrong time :'(.

Thankfully, as users, we can control if an app is allowed to display notifications and in what form. This means, as developers, we must ask for permission... ;).

So, our implementation starts with requesting permissions. In AppDelegate.swift this is done within a app delegate method  application/didFinishLaunchingWithOptions  - the method is already there for us, we just need to add our code there:

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (granted, error) in
            if (granted) {
                print ("We'll be able to set Hot Reminders!")
            }
            else {
                print ("We need to prove the app amazing so the user will change their mind!")
            }
        }
        
        return true
    }

Let's review that magical single line of code:

  • We are accessing  UNUserNotificationCenter  object and its current representation available for the app (by the way,  UN  prefix stands for User Notifications and indicates that the name comes from UserNotifications framework) 

  • Calling requestAuthorization on the current User Notification Center object. This method triggers an alert view, which asks the user whether they will allow the app to send notifications.

Let's now take a closer look at the parameters of the method:

  • The first parameter takes an array of options. These options indicate which kind of notifications your app is using. Here are the available options:

    • badge

    • sound

    • alert

    • carPlay

  •  The second parameter is a hander block, that's called after the user has responded to the alert view. The granted parameter tells us whether the user allowed notifications or not.

After completion of this request, if the user gave the permission, our app is ready to schedule notifications, if not - we need to impress the user with the app functionality, so that they change their mind.

Schedule Notifications

Sending local notifications incorporate three components:

  • content

  • a trigger

  • a request.

We'll be generating it with our view controller code, so let's import the UserNotifications framework to ViewController.swift:

import UserNotifications

And create a helper method to manage the local notifications business:

func manageLocalNotifications() {
        
}

Then, we need to call it every time we perform some actions with tasks. Since we are providing sample notifications to start, we'll also call this method in our app configuration method:

    func configure() {
        tableView.delegate = self
        tableView.dataSource = self
        
        populateInitialTasks()
        
        updateProgress()
        
        registerForKeyboardNotifications()
        
        manageLocalNotifications()
    }

We'll add it to the rest of the code shortly.

Let’s start by looking at the content and create a sample object within the new method:

let content = UNMutableNotificationContent()
content.title = "Title"
content.body = "Body"
content.sound = UNNotificationSound.default()

We're creating an object of  UNMutableNotificationContent  class and configuring its essential properties - title and body and assigning a default notification sound to its respective property.

Next, we need to create a trigger object -  UNTimeIntervalNotificationTrigger  : 

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)

The initializer of UNTimeIntervalNotificationTrigger  takes two parameters - time interval that needs to pass from the time of scheduling until the notification needs to popup. We'll use 5 seconds for testing. The second parameter indicates whether the notification needs to be repeated - we'll have a one time reminder for the moment.

Last object to create is a request object -  UNNotificationRequest  :

let request = UNNotificationRequest(identifier: "TestIdentifier", content: content, trigger: trigger)

The initializer of UNNotificationRequest  takes 3 parameters - the identifier - what we can use to access that notification at a later time, and the 2 objects we just created - content and trigger.

Pretty straight forward so far :zorro:!

And finally, we are ready to schedule our notifications:

UNUserNotificationCenter.current().add(request, withCompletionHandler:nil)

So the complete code of manageNotifications()  method looks like this:

    func manageLocalNotifications() {
        
        // create content
        let content = UNMutableNotificationContent()
        content.title = "Title"
        content.body = "Body"
        content.sound = UNNotificationSound.default()
        
        // create trigger
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
        
        // create request
        let request = UNNotificationRequest(identifier: "TestIdentifier", content: content, trigger: trigger)
        
        // schedule notification
        UNUserNotificationCenter.current().add(request, withCompletionHandler:nil)
        
    }

Let's test it! Remember, the notifications are only displayed when the user is NOT using the app! So, after you launch the app in simulator and after giving permission to send notifications, click home button to observe the notification appear:

Local Notifications test
Local Notification test

Now let's implement the actual content. We'll be creating a single reminder notification that will display hot tasks progress similarly to how we report it on the table header within the app. To optimize the code, let's move out the notification scheduling functionality into a separate helper method:

func scheduleLocalNotification(title: String?, body: String?) {
        let identifier = "HostListSummary"
        let notificationCenter = UNUserNotificationCenter.current()
        
        // remove previously scheduled notifications
        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
        
        if let newTitle = title, let newBody = body {
            // create content
            let content = UNMutableNotificationContent()
            content.title = newTitle
            content.body = newBody
            content.sound = UNNotificationSound.default()
            
            // create trigger
            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
            
            // create request
            let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
            
            // schedule notification
            notificationCenter.add(request, withCompletionHandler:nil)
        }
     }

Let's review the alterations:

  • The function is taking 2 parameters - those are the items we need for the content. 

  • Since we are about to set an updated notification, we need to remove all that were previously scheduled and not yet delivered. We are using  removeDeliveredNotifications/withIdentifiers  method of Notification Center for that.

  • We're checking whether either of the parameters is nil and using it as an indication that we only needed to remove previous reminders. Otherwise proceed with scheduling a new one.

  • The rest of the content remains the same from our test example, except we replaced the title and body temporary content with the parameters.

Now let's compose the real content for the reminder:

func manageLocalNotifications() {
        // prepare content
        let totalTasks = priorityTasks.count + bonusTasks.count
        let completedTasks = priorityTasks.filter { (task) -> Bool in
            return task.completed == true
            }.count + bonusTasks.filter { (task) -> Bool in
                return task.completed == true
                }.count
        
        var title: String?
        var body: String?

        if totalTasks == 0 { // no tasks
            title = "It's lonely here"
            body = "Add some tasks!"
        }
        else if completedTasks == 0 { // nothing completed
            title = "Get started!"
            body = "You've got \(totalTasks) hot tasks to go!"
        }
        else if completedTasks < totalTasks { // completedTasks less totalTasks
            title = "Progress in action!"
            body = "\(completedTasks) down \(totalTasks - completedTasks) to go!"
        }
        
        // schedule (or remove) reminders
        scheduleLocalNotification(title: title, body: body)
    }

Let's review the view functionality:

  • We are calculating the total number of tasks and number of completed ones.

  • Declaring optional variables to hold title and body

  • Analyzing the numbers and either composing suitable title and body or leaving them nil. In case of nil values we are expecting the reminders to be removed if any were scheduled earlier and not yet delivered.  

Let's test our implementation! Here it is:

HotList reminder notification!
HotList reminder notification!

Set on Repeat!

Now that we have evidence that our reminders work, let's set them on repeat! - every 2 hours!

So, let's alter the trigger creation:

let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 120, repeats: true)

And call manageLocalNotifications method in all the spots related to the task management within the app:

  • Move and delete task in editActionsForRowAt

                let delete = UITableViewRowAction(style: .destructive, title: "Delete") { (action, indexPath) in
                    self.deleteTask(at: indexPath)
                    self.manageLocalNotifications()
                    self.updateProgress()
                }
                let move = UITableViewRowAction(style: .normal, title: moveCaption) { (action, indexPath) in
                    task.priority = (task.priority == .top) ? .bonus : .top
                    self.deleteTask(at: indexPath)
                    self.insertTask(task, at: moveToIndexPath)
                }
  • Add new task in newTaskCell delegate method: 

        func newTaskCell(_ cell: NewTaskTableViewCell, newTaskCreated caption: String) {
            // create new HOT task:)
            let newTask = HotTask.init(caption: caption)
            
            // insert a new row in the beginning of priority section
            insertTask(newTask, at: IndexPath(row: 0, section: 1))
            
            manageLocalNotifications()
            updateProgress()
        }
  • Task completion state changed in taskCell delegate method: 

        func taskCell(_ cell: TaskTableViewCell, completionChanged completion: Bool) {
            // identify indexPath for a cell
            if let indexPath = tableView.indexPath(for: cell) {
                // fetch datasource for indexPath
                if let task = hotTaskDataSource(indexPath: indexPath) {
                    // update the completion state
                    task.completed = completion
                    
                    manageLocalNotifications()
                    updateProgress()
                }
            }
        }

And here's the final code of the view controller:

import UIKit
import UserNotifications

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, TaskCellDelegate, NewTaskCellDelegate {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var progressLabel: UILabel!
    
    var priorityTasks = [HotTask]()
    var bonusTasks = [HotTask]()
    
    // MARK: Cells delegates
    
    func newTaskCell(_ cell: NewTaskTableViewCell, newTaskCreated caption: String) {
        // create new HOT task:)
        let newTask = HotTask.init(caption: caption)
        
        // insert a new row in the beginning of priority section
        insertTask(newTask, at: IndexPath(row: 0, section: 1))
        
        manageLocalNotifications()
        updateProgress()
    }
    
    func taskCell(_ cell: TaskTableViewCell, completionChanged completion: Bool) {
        // identify indexPath for a cell
        if let indexPath = tableView.indexPath(for: cell) {
            // fetch datasource for indexPath
            if let task = hotTaskDataSource(indexPath: indexPath) {
                // update the completion state
                task.completed = completion
                
                manageLocalNotifications()
                updateProgress()
            }
        }
    }

    // MARK: Keyboard management
    
    func registerForKeyboardNotifications() {
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: .UIKeyboardWillShow, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: .UIKeyboardWillHide, object: nil)
    }
    
    @objc func keyboardWillShow(notification: NSNotification) {
        let keyboardFrame = notification.userInfo![UIKeyboardFrameEndUserInfoKey] as! CGRect
        adjustLayoutForKeyboard(targetHeight: keyboardFrame.size.height)
    }
    
    @objc func keyboardWillHide(notification: NSNotification){
        adjustLayoutForKeyboard(targetHeight: 0)
    }
    
    func adjustLayoutForKeyboard(targetHeight: CGFloat) {
        tableView.contentInset.bottom = targetHeight
    }
    
    // MARK: Notifications
    
    func manageLocalNotifications() {
        // prepare content
        let totalTasks = priorityTasks.count + bonusTasks.count
        let completedTasks = priorityTasks.filter { (task) -> Bool in
            return task.completed == true
            }.count + bonusTasks.filter { (task) -> Bool in
                return task.completed == true
                }.count
        
        var title: String?
        var body: String?
        
        if totalTasks == 0 { // no tasks
            title = "It's lonely here"
            body = "Add some tasks!"
        }
        else if completedTasks == 0 { // nothing completed
            title = "Get started!"
            body = "You've got \(totalTasks) hot tasks to go!"
        }
        else if completedTasks < totalTasks { // completedTasks less totalTasks
            title = "Progress in action!"
            body = "\(completedTasks) down \(totalTasks - completedTasks) to go!"
        }
        
        // schedule (or remove) reminders
        scheduleLocalNotification(title: title, body: body)
        
    }
    
    func scheduleLocalNotification(title: String?, body: String?) {
        let identifier = "HostListSummary"
        let notificationCenter = UNUserNotificationCenter.current()
        
        // remove previously scheduled notifications
        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
        
        if let newTitle = title, let newBody = body {
            // create content
            let content = UNMutableNotificationContent()
            content.title = newTitle
            content.body = newBody
            content.sound = UNNotificationSound.default()
            
            // create trigger
            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 120, repeats: true)
            
            // create request
            let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
            
            // schedule notification
            notificationCenter.add(request, withCompletionHandler:nil)
        }
    }
    
    // MARK: TableView delegates
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 3
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        switch section {
        case 0:
            return 1
        case 1:
            return priorityTasks.count
        case 2:
            return bonusTasks.count
        default:
            return 0
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        switch indexPath.section {
        case 0:
            let cell = tableView.dequeueReusableCell(withIdentifier: "NewTaskCellID", for: indexPath) as! NewTaskTableViewCell
            cell.delegate = self
            return cell
        case 1:
            let cell = tableView.dequeueReusableCell(withIdentifier: "TaskCellID", for: indexPath) as! TaskTableViewCell
            let task = hotTaskDataSource(indexPath: indexPath)
            cell.setCaption(task?.caption)
            cell.delegate = self
            return cell
        case 2:
            let cell = tableView.dequeueReusableCell(withIdentifier: "TaskCellID", for: indexPath) as! TaskTableViewCell
            let task = hotTaskDataSource(indexPath: indexPath)
            cell.setCaption(task?.caption)
            cell.delegate = self
            return cell
        default:
            return UITableViewCell.init()
        }
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        switch section {
        case 1:
            return "Top Priority"
        case 2:
            return "Bonus"
        default:
            return nil
        }
    }
    
    func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        return nil
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.cellForRow(at: indexPath)?.accessoryType = .checkmark
    }
    
    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        tableView.cellForRow(at: indexPath)?.accessoryType = .none
    }
    
    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        switch indexPath.section {
        case 0:
            return false
        default:
            return true
        }
    }
    
    func insertTask(_ task: HotTask?, at indexPath: IndexPath?) {
        if let task = task, let indexPath = indexPath {
            // put the table view in updating mode
            tableView.beginUpdates()
            
            // add new object to the datasource array
            if (indexPath.section == 1) {
                priorityTasks.insert(task, at: indexPath.row)
            }
            else {
                bonusTasks.insert(task, at: indexPath.row)
            }
            
            // insert a new cell to the table
            tableView.insertRows(at: [indexPath], with: .automatic)
            
            // finish updating table
            tableView.endUpdates()
        }
    }
    
    func deleteTask(at indexPath: IndexPath?) {
        if let indexPath = indexPath {
            // put the table view in updating mode
            tableView.beginUpdates()
            
            // add new object to the datasource array
            if (indexPath.section == 1) {
                priorityTasks.remove(at: indexPath.row)
            }
            else {
                bonusTasks.remove(at: indexPath.row)
            }
            
            // insert a new cell to the table
            tableView.deleteRows(at: [indexPath], with: .automatic)
            
            // finish updating table
            tableView.endUpdates()
        }
    }
    
    func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
        var actions: [UITableViewRowAction]?
        var moveCaption: String?
        var moveToIndexPath: IndexPath?
        
        switch indexPath.section {
        case 1:
            moveCaption = "Move to Bonus"
            moveToIndexPath = IndexPath(row: 0, section: 2)
        case 2:
            moveCaption = "Move to Priority"
            moveToIndexPath = IndexPath(row: 0, section: 1)
        default:
            return actions
        }
        
        if let task = hotTaskDataSource(indexPath: indexPath) {
            let delete = UITableViewRowAction(style: .destructive, title: "Delete") { (action, indexPath) in
                self.deleteTask(at: indexPath)
                self.manageLocalNotifications()
                self.updateProgress()
            }
            let move = UITableViewRowAction(style: .normal, title: moveCaption) { (action, indexPath) in
                task.priority = (task.priority == .top) ? .bonus : .top
                self.deleteTask(at: indexPath)
                self.insertTask(task, at: moveToIndexPath)
            }
            actions = [delete, move]
        }
        return actions
    }
    
    func hotTaskDataSource(indexPath: IndexPath) -> HotTask? {
        switch indexPath.section {
        case 1:
            return priorityTasks[indexPath.row]
        case 2:
            return bonusTasks[indexPath.row]
        default:
            return nil
        }
    }
    
    func updateProgress() {
        
        // calculate the initial values for task count
        let totalTasks = priorityTasks.count + bonusTasks.count
        let completedTasks = priorityTasks.filter { (task) -> Bool in
            return task.completed == true
            }.count + bonusTasks.filter { (task) -> Bool in
                return task.completed == true
                }.count
        
        // declare a caption variable
        var caption = "What's going on?!"
        
        // handle range possible scenarios
        if totalTasks == 0 { // no tasks
            caption = "It's  lonely here - add some tasks!"
        }
        else if completedTasks == 0 { // nothing completed
            caption = "Get started - \(totalTasks) to go!"
        }
        else if completedTasks == totalTasks { // all completed
            caption = "Well done - \(totalTasks) completed!"
        }
        else { // completedTasks less totalTasks
            caption = "\(completedTasks) down \(totalTasks - completedTasks) to go!"
        }
        
        // assign the progress caption to the label
        progressLabel.text = caption
    }
    
    func populateInitialTasks() {
        priorityTasks.removeAll()
        
        priorityTasks.append(HotTask.init(caption: "Pickup MacBook Pro from Apple store"))
        priorityTasks.append(HotTask.init(caption: "Practice Japanese"))
        priorityTasks.append(HotTask.init(caption: "Buy ingredients for a cake for Alie's bday"))
        
        bonusTasks.removeAll()
        let hotTask = HotTask.init(caption: "Shop for funnky socks")
        hotTask.priority = .bonus
        bonusTasks.append(hotTask)
    }
    
    func configure() {
        tableView.delegate = self
        tableView.dataSource = self
        
        populateInitialTasks()
        
        updateProgress()
        
        registerForKeyboardNotifications()
        
        manageLocalNotifications()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configure()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }


}

Well Done!

Well done competing another iOS app! While creating it, we've learned common elements of any iOS app!

Let's Recap!

  • iOS apps support 2 types of user notifications: Local & Remote.

  • Local notifications originate and are delivered on the same device. Remote notifications come from a server.

  • User Notifications functionality is supported by UserNotifications framework.

  • To be able to send notifications an app must ask for permission from the user.

  • Local notifications require 3 components: Content, Trigger and Request.

Example of certificate of achievement
Example of certificate of achievement