Picture

Hi, I'm Sammy Gutierrez.

I love learning about Swift and iOS!

Improving Keyboard Notifications

After reading Ole Begemann’s insightful post on having to manually deallocate NotificationCenter observer tokens, I wanted to know if there was a way to register for keyboard notifications using this strategy. Let’s see if we can continue to use typed notifications and improve upon it using a protocol.

A Quick Overview of Typed Notifications

I strongly suggest that you watch Chris and Florian’s fantastic talk on typed notifications and then come back here. Mines Bitcoin on your browser for twenty minutes. Okay, welcome back. To summarize their talk, they discuss adding a wrapper extension for NotificationCenter.addObserver(forName:object:queue:using:) which takes in a NotificationDescriptor that will convert a notification object into a generic type.

For example, if we wanted to register for keyboard events, we can define a KeyboardPayload that will store the typed properties of the notification’s userInfo dictionary once we’ve received a notification.

This works great and can be used as follows:

/// A wrapper which contains a notification and a converter function which converts a notification to type `A`
struct NotificationDescriptor<A> {
  let name: Notification.Name
  let convert: (Notification) -> A
}

/// A container for a notification token belonging to the `notificationCenter` property
/// - Note: We can use this as a property on a view controller so that when the VC is deallocated, the notification token is automatically removed
final class NotificationToken {
  private let token: NSObjectProtocol
  private let notificationCenter: NotificationCenter

  init(token: NSObjectProtocol, notificationCenter: NotificationCenter) {
    self.token = token
    self.notificationCenter = notificationCenter
  }

  deinit {
    notificationCenter.removeObserver(token)
  }
}

extension NotificationCenter {
  /// Convenience wrapper for use with `NotificationDescriptor` which will register for notifcations and handle
  /// the conversion of the notification into a generic type
  /// - Parameters:
  ///   - descriptor: This will help convert the notification received into the generic type `A`
  ///   - block: The block that runs when a notification is received
  /// - Returns: A `NotificationToken` containing the a token and its `NotificationCenter`
  func addObserver<A>(descriptor: NotificationDescriptor<A>, using block: @escaping (A) -> ()) -> NotificationToken {
    let token = addObserver(forName: descriptor.name, object: nil, queue: nil) { notification in
      block(descriptor.convert(notification))
    }
    return NotificationToken(token: token, notificationCenter: self)
  }
}

/// A payload which contains typed properties to the objects within a keyboard notification's `userInfo` dictionary
/// - Note: [Keyboard Notification User Info Keys](https://developer.apple.com/documentation/uikit/uiwindow/keyboard_notification_user_info_keys)
struct KeyboardPayload {
  let startingFrame: CGRect
  let endingFrame: CGRect
  let animationDuration: TimeInterval
  let animationCurve: UIViewAnimationCurve
  let isLocal: Bool
}

extension KeyboardPayload {
  /// Decodes a keyboard notification's `userInfo` dictionary properties
  /// - Parameter notification: A keyboard notification
  init(notification: Notification) {
    guard let userInfo = notification.userInfo else { fatalError() }
    animationDuration = (userInfo[UIKeyboardAnimationDurationUserInfoKey] as! NSNumber).doubleValue
    let animationCurveValue = (userInfo[UIKeyboardAnimationCurveUserInfoKey] as! NSNumber).intValue
    animationCurve = UIViewAnimationCurve(rawValue: animationCurveValue)!
    isLocal = (userInfo[UIKeyboardIsLocalUserInfoKey] as! NSNumber).boolValue
    startingFrame = (userInfo[UIKeyboardFrameBeginUserInfoKey] as! NSValue).cgRectValue
    endingFrame = (userInfo[UIKeyboardFrameEndUserInfoKey] as! NSValue).cgRectValue
  }
}

// MARK: - Example Use Case

final class ViewController: UIViewController {

  /// This property will keep the token around as long as this VC exists
  private var keyboardWillShowToken: NotificationToken?

  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    let keyboardWillShowNotification = NotificationDescriptor<KeyboardPayload>(name: Notification.Name.UIKeyboardWillShow, 
                                                                               convert: KeyboardPayload.init)
    keyboardWillShowToken = NotificationCenter.default.addObserver(descriptor: keyboardWillShowNotification,
                                                                   using: { payload in
      // Do fancy stuff with the keyboard here using the payload
    }
  }

  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    keyboardWillShowToken = nil
  }
}

More Tokens Please

Cool! Now I can listen to the keyboard’s willShow notification and I have access to the typed properties of the keyboard’s userInfo dictionary. I’m prone to FOMO and I want to listen to all of the other exciting keyboard events.

Here’s what I want to be able to do:

  1. Choose which keyboard events I want to observe.
  2. Deregister the keyboard observers at any time. If I do forget to deregister them, I also don’t want to worry about it causing a retain cycle.
  3. Have a callback function with the type of notification that was called and the typed payload that came with it.

Can we get a protocol to do this for us? Perhaps we could use it like this:

// MARK: - My Dream
final class ViewController: UIViewController {

  let notificationCenter: NotificationCenter = .default
  var keyboardNotificationTokens: [NotificationToken] = []

  override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated: animated)
    registerKeyboardNotifications([.willShow, .willHide])
  }

  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    deregisterKeyboardNotifications()
  }
}

extension ViewController: KeyboardNotificationObserver {

  func receivedKeyboardNotification(_ notificationType: KeyboardNotification, payload: KeyboardPayload) {
    print("Got notification: \(notificationType) with payload: \(payload)")
    switch notificationType {
      case .willShow:
        print("It's my time to shine!")
      case .willHide:
        print("Forget this, I'm outta here.")
      default:
        break
    }
  }
}

Let’s Write this Bad Boy!

Let’s start defining what this mysterious KeyboardNotificationObserver protocol might look like:

/// Container for all of the possible keyboard events
/// Could also be an `OptionSet`
/// - Note: [Keyboard Management](https://developer.apple.com/library/content/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html)
enum KeyboardNotificationType {
  case willShow
  case didShow
  case willHide
  case didHide
  case willChangeFrame
  case didChangeFrame

  /// The notification name for each notification type
  fileprivate var name: Notification.Name {
    switch self {
    case .willShow: return .UIKeyboardWillShow
    case .didShow: return .UIKeyboardDidShow
    case .willHide: return .UIKeyboardWillHide
    case .didHide: return .UIKeyboardDidHide
    case .willChangeFrame: return .UIKeyboardWillChangeFrame
    case .didChangeFrame: return .UIKeyboardDidChangeFrame
    }
  }
}

/// A class that conforms to this protocol listens to keyboard events after registering for notifications.
/// This protocol automatically handles the the deallocation of any keyboard notification tokens.
protocol KeyboardNotificationObserver: class {

  /// The `NotificationCenter` to use that will add notification observers
  var notificationCenter: NotificationCenter { get }
  
  /// The storage for the keyboard notification tokens
  var keyboardNotificationTokens: [NotificationToken] { get set }

  /// This is called whenever a notification has been received
  /// - Parameters:
  ///   - notificationType: The type of keyboard notification that was received
  ///   - payload: The typed `KeyboardPayload`
  func receivedKeyboardNotification(_ notificationType: KeyboardNotificationType, payload: KeyboardPayload)

  /// Registers for the keyboard notifications
  /// - Parameter notifications: An array of the type of keyboard notifications we want to observe
  func registerKeyboardNotifications(_ notifications: [KeyboardNotificationType])

  /// Calling this will remove all registered keyboard notifications and their tokens
  func deregisterKeyboardNotifications()
}

The class that conforms to this protocol should really only need to implement receivedKeyboardNotification(_:payload:). Let’s see if we can write the default implementations for registerKeyboardNotifications(_:) and deregisterKeyboardNotifications in a protocol extension.

extension KeyboardNotificationObserver {

  /// Let's not get too fancy and just use the default `NotificationCenter`
  var notificationCenter: NotificationCenter { return .default }

  func registerKeyboardNotifications(_ notifications: [KeyboardNotificationType]) {
    keyboardNotificationTokens = notifications.map { keyboardNotificationType in
      let keyboardNotificationDescriptor = NotificationDescriptor<KeyboardPayload>(name: keyboardNotificationType.name,
                                                                                   convert: KeyboardPayload.init)
      return notificationCenter.addObserver(descriptor: keyboardNotificationDescriptor,
                                            using: { [weak self] keyboardPayload in
        self?.didReceiveKeyboardNotification(keyboardNotificationType,
                                             payload: keyboardPayload)
      })
    }
  }
  
  func deregisterKeyboardNotifications() {
    keyboardNotificationTokens.removeAll()
  }
}

And that’s it! I can use this protocol just the way I wanted. Even if we do forget to deregister any notification tokens, they will still be deallocated when the view controller is deinitialized. We could even use this strategy for other types of notifications. Wow!

I’ve uploaded a sample project on GitHub if you want to play around with this.

– Sammy

Sent from my iPhone.