Categories
iOS Swift SwiftUI WatchOS

Dynamic user notification on Apple Watch with SwiftUI

Apps which integrate push or local notifications can customise notifications on Apple Watch. Let’s go through steps required for adding dynamic notifications on Apple Watch. Sample use case is an app which reminds when a plant needs watering. We’ll only concentrate on adding dynamic notification view and leave out sending local notifications from the iOS app.

Adding build target for rich notifications on Apple Watch

If Apple Watch app does not exist in the project the first step is to add it. In Xcode, we’ll add a new build target and configure it to include notification scene. In Xcode, open new target view: File>Target and select Watch App for iOS App template.

Watch App for iOS App template in File>Target.

Make sure SwiftUI is selected in the user interface selection and “Include Notification Scene” is also selected. We’ll embed it in the companion app so make sure current iOS app target is set to “Embed in Companion App”. As a side note, since iOS 13 and WatchOS 6, Apple Watch apps can be independent as well.

Watch App for iOS app build target configuration with notification scene.

Click on Finish and then Xcode will ask about activating the new scheme, click on active. It will just select the new target and we can build it right away. When inspecting the project, Xcode added two targets: watch app and extension. App contains storyboard and extension contains all the code. Storyboard is wired up so that the scene displays HostingController which is WKHostingController subclass and is responsible of hosting your SwiftUI view in the Apple Watch app. In addition, there are scenes for static and dynamic notifications. We are interested in creating dynamic notifications and in the Storyboard we can see that the dynamic view is provided by NotificationController (subclass of WKUserNotificationHostingController) which hosts SwiftUI view for the notification. There we can provide the custom interface for our user notification. Dynamic notification view is selected if the notification category matches with the one defined in the Storyboard.

If you need more information how to set up Xcode project please check: “Setting Up a watchOS Project”.

Parsing notification payload and setting up dynamic notification view

NotificationController’s responsibility is to consume user notification’s payload and configuring the SwiftUI view with it. User notification is provided by didReceive function and there we can extract the information needed for showing the view. When it comes to locally testing the dynamic view, we can add required data to the PushNotificationPayload.apns file. As we show information about plants, let’s add example plant object to the file. Also, we change category to something meaningful. Make sure to update Storyboard when setting new category.

{
    "aps": {
        "alert": {
            "body": "Test message",
            "title": "Optional title",
            "subtitle": "Optional subtitle"
        },
        "category": "WATERING_REMINDER",
        "thread-id": "plantid123"
    },
    
    "plant": {
        "id": "plantid123",
        "name": "Aloe",
        "lastDate": 1579937802,
        "nextDate": 1580515200
    }
}
Notification category in Storyboard for defining the connection to dynamic notification view.

Plant related information is available when accessing UNNotification’s request.content.userInfo. We can use Decodable and JSONDecoder for converting Dictionary representing the plant into value type. As JSONDecoder requires JSON data then we can first use JSONSerialization and then passing the JSON data to JSONDecoder. Alternatively we could also manually read values from the userInfo dictionary and then creating the value type. Note that we use view model for providing data to SwiftUI view and not the Plant type directly.

struct Plant: Decodable {
    let id: String
    let name: String
    let lastDate: Date
    let nextDate: Date
}

do {
	let plantInfo = notification.request.content.userInfo["plant"] as! [String: Any]
	let data = try JSONSerialization.data(withJSONObject: plantInfo, options: [])
	let decoder = JSONDecoder()
	decoder.dateDecodingStrategy = .secondsSince1970
	let plant = try decoder.decode(Plant.self, from: data)
	viewModel = NotificationViewModel(plant: plant)
}
catch let nsError as NSError {
	print(nsError.localizedDescription)
}

In addition, we would like to add 3 actions user can take: marking the plant as watered, deferring the reminder for couple of hours, or scheduling it for tomorrow. Actions are represented with instances of UNNotificationAction and when user taps on any of those, UNUserNotificationCenter’s delegate method is called with the identifier in the companion iOS app (userNotificationCenter(_:didReceive:withCompletionHandler:)).

let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
notificationActions = [
	UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
	UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
	UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
]

The full implementation of the NotificationController becomes like this (including the creation of SwiftUI):

final class NotificationController: WKUserNotificationHostingController<NotificationView> {
    private var viewModel: NotificationViewModel?
    
    override var body: NotificationView {
        return NotificationView(viewModel: viewModel!)
    }
    
    override func didReceive(_ notification: UNNotification) {
        do {
            let plantInfo = notification.request.content.userInfo["plant"] as! [String: Any]
            let data = try JSONSerialization.data(withJSONObject: plantInfo, options: [])
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .secondsSince1970
            let plant = try decoder.decode(Plant.self, from: data)
            viewModel = NotificationViewModel(plant: plant)
        }
        catch let nsError as NSError {
            print(nsError.localizedDescription)
        }
        
        let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.")
        let laterTitle = NSLocalizedString("NotificationAction_Later", comment: "Later button title in notification.")
        let tomorrowTitle = NSLocalizedString("NotificationAction_Tomorrow", comment: "Tomorrow button title in notification.")
        notificationActions = [
            UNNotificationAction(identifier: "water_done", title: doneTitle, options: []),
            UNNotificationAction(identifier: "water_later", title: laterTitle, options: []),
            UNNotificationAction(identifier: "water_tomorrow", title: tomorrowTitle, options: [])
        ]
    }
}

NotificationView presenting the dynamic notification

Previously mentioned view model NotificationViewModel provides text for NotificationView and it looks pretty simple, mainly dealing with creating strings with formatted dates. As we only want to show day and month then we need to create dateFormat using current locale.

struct NotificationViewModel {
    private let plant: Plant
    
    init(plant: Plant) {
        self.plant = plant
    }
    
    var title: String {
        return plant.name
    }
    
    var subtitle: String {
        return NSLocalizedString("NotificationView_Subtitle", comment: "Notification suggestion text")
    }
    
    private let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "dMMMM", options: 0, locale: .current)
        return formatter
    }()
    
    var lastWatering: String {
        let format = NSLocalizedString("NotificationView_LastWatering", comment: "Last watering date.")
        return String(format: format, dateFormatter.string(from: plant.lastDate))
    }
    
    var nextWatering: String {
        let format = NSLocalizedString("NotificationView_NextWatering", comment: "Next watering date.")
        return String(format: format, dateFormatter.string(from: plant.nextDate))
    }
}

SwiftUI view is also pretty simple with containing 4 text labels and one divider.

struct NotificationView: View {
    let viewModel: NotificationViewModel
    
    var body: some View {
        VStack {
            Text(viewModel.title).font(.title)
            Text(viewModel.subtitle).font(.subheadline)
            Divider()
            Text(viewModel.lastWatering).font(.body).multilineTextAlignment(.center)
            Text(viewModel.nextWatering).font(.body).multilineTextAlignment(.center)
        }
    }
}
Dynamic notification with 4 labels and divider.
Buttons in dynamic notification.

Summary

We added Apple Watch app to an existing iOS app and implemented dynamic notification view for one notification category. We looked into how to parse data associated with the user notification, create SwiftUI view and add action buttons. Next steps would be to handle notification actions in the companion iOS app based on the notification button identifier.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example project

WaterMyPlants (GitHub)

Categories
iOS Swift UIKit

Observing keyboard visibility on iOS

Almost every app needs a way of inserting information using keyboard. When keyboard shows up, we do not want to keep content behind the keyboard hidden and instead, allow user to see it. UIResponder contains several notifications we can use to adjust the layout.

Keyboard change notifications

UIResponder contains a list of notifications and user info keys. We have notifications for reacting to visibility and frame changes (for example when rotating device). Notification’s userInfo contains a variety of information about the change. What makes this API a little bit difficult to use is parsing the user info every time we need to use those notifications. If we need to observe keyboard in several view controllers then the amount of code of setting up observation and doing type casting starts to build up. Therefore it makes more sense to have an object handling the observation and type casting user info keys.

extension UIResponder {
public class let keyboardWillShowNotification: NSNotification.Name
public class let keyboardDidShowNotification: NSNotification.Name
public class let keyboardWillHideNotification: NSNotification.Name
public class let keyboardDidHideNotification: NSNotification.Name
public class let keyboardWillChangeFrameNotification: NSNotification.Name
public class let keyboardDidChangeFrameNotification: NSNotification.Name
public class let keyboardFrameBeginUserInfoKey: String // NSValue of CGRect
public class let keyboardFrameEndUserInfoKey: String // NSValue of CGRect
public class let keyboardAnimationDurationUserInfoKey: String // NSNumber of double
public class let keyboardAnimationCurveUserInfoKey: String // NSNumber of NSUInteger (UIViewAnimationCurve)
public class let keyboardIsLocalUserInfoKey: String // NSNumber of BOOL
}

Using a KeyboardObserver

KeyboardObserver is a lightweight object observing keyboard related notifications and calling the changeHandler when any of the notifications is received. User info and notification type information is represented with struct Info. Before looking into how it is implemented, let’s take a look on the example.

final class ViewController: UIViewController {
@IBOutlet weak var scrollView: UIScrollView!
private var keyboardObserver: KeyboardObserver?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
keyboardObserver = KeyboardObserver(changeHandler: { [weak self] (info) in
guard let self = self else { return }
switch info.event {
case .willShow:
self.scrollView.contentInset.bottom = info.endFrame.height
case .willHide:
self.scrollView.contentInset.bottom = 0
default:
break
}
})
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
keyboardObserver = nil
}
}

Here we can see that setting up observer is straight-forward and accessing end frame of the keyboard does not require any type casting. Compare it with adding observers to those notifications and then using conditional casts for getting relevant information in the view controller. 

Creating a KeyboardObserver

But let’s now take a look on how it is implemented and see how much less code we need to write in the future. KeyboardObserver is initialised with a changeHandler closure like seen in the previous paragraph. Initialiser retains the handler and sets up observers for all the relevant notifications. For simplicity, we are observing all the notifications but it would also be possible to have an extra argument defining a set of Events and then observing only the notifications we really want to react to.

Type casting relies completely on promises made by UIKit. As UIKit promises that user info always contains those values, we can avoid having any optional values in the Info struct. Therefore it is simpler to use the struct later on as no unwrapping is required.

final class KeyboardObserver {
enum Event {
case willShow, didShow, willHide, didHide, willChangeFrame, didChangeFrame
}
struct Info {
let animationCurve: UIView.AnimationCurve
let animationDuration: TimeInterval
let isLocal: Bool
let beginFrame: CGRect
let endFrame: CGRect
let event: Event
}
let changeHandler: (Info) -> ()
init(changeHandler: @escaping (Info) -> ()) {
self.changeHandler = changeHandler
let notifications: [Notification.Name] = [UIResponder.keyboardWillShowNotification,
UIResponder.keyboardDidShowNotification,
UIResponder.keyboardWillHideNotification,
UIResponder.keyboardDidHideNotification,
UIResponder.keyboardWillChangeFrameNotification,
UIResponder.keyboardDidChangeFrameNotification]
notifications.forEach { (notification) in
NotificationCenter.default.addObserver(self, selector: #selector(KeyboardObserver.keyboardChanged(_:)), name: notification, object: nil)
}
}
@objc private func keyboardChanged(_ notification: Notification) {
guard let userInfo = notification.userInfo else { fatalError() }
let event: Event = {
switch notification.name {
case UIResponder.keyboardWillShowNotification: return .willShow
case UIResponder.keyboardDidShowNotification: return .didShow
case UIResponder.keyboardWillHideNotification: return .willHide
case UIResponder.keyboardDidHideNotification: return .didHide
case UIResponder.keyboardWillChangeFrameNotification: return .willChangeFrame
case UIResponder.keyboardDidChangeFrameNotification: return .didChangeFrame
default:
fatalError("Unknown change notification \(notification).")
}
}()
changeHandler(Info(event: event, userInfo: userInfo))
}
}
fileprivate extension KeyboardObserver.Info {
init(event: KeyboardObserver.Event, userInfo: [AnyHashable: Any]) {
self.event = event
animationCurve = {
let rawValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! Int
return UIView.AnimationCurve(rawValue: rawValue)!
}()
animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! TimeInterval
isLocal = userInfo[UIResponder.keyboardIsLocalUserInfoKey] as! Bool
beginFrame = userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as! CGRect
endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
}
}

Summary

We took a look on how to avoid observing multiple notifications and type casting notification user info values on the view controller level. Instead, we created a separate object handling observing and type casting and gives us a simple and concise API to work with.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Example project

KeyboardObserver Xcode 10.1, Swift 4.2

References

UIResponder (Apple)