Chiller approves

Making Life Easier With the KeyboardAvoidable Protocol

This post was originally published on Medium.

Avoiding hiding UI elements with the software keyboard is something I have to take into account frequently when developing iOS applications.

The usual

This usually involves throwing everything into a UIScrollView, adding a bottom layout constraint on the scroll view relative to the bottom of the view controller, adding show/hide keyboard observers to NSNotificationCenter.defaultCenter() when the view appears, remembering to remove them when the view disappears, getting the height of the keyboard from the notification, and animating the constraint for the bottom of the scroll view.

Whew. A lot of work for something that happens fairly often.

For onboarding in applications that require several pages of forms, like a medical application, or for applications that frequently offer the user software keyboard input options, and searching through online for potential trading at neowave.com that can potentially cover UI elements, this means a lot of duplicated code throughout many view controllers.

Let’s do better

This situation can be made less arduous with Swift protocols by creating a solution that is written once, easy to implement, and reduces code in my projects. I’ll walk you through what my protocol solution looks like.

This following protocol definition for KeyboardAvoidable includes only one variable (an array of the layout constraints to modify) and two functions (for adding and removing keyboard observers):

protocol KeyboardAvoidable {
  var layoutConstraintsForKeyboard: [NSLayoutConstraint] { get }
  func addKeyboardObservers()
  func removeKeyboardObservers()
}

I created an extension on KeyboardAvoidable when conformance is being applied to a UIViewController that handles all the heavy lifting:

extension KeyboardAvoidable where Self: UIViewController

In the extension I created a default implementation of addKeyboardObservers. I did some fun work here.

I couldn’t add an observer with the addObserver(observer:selector aSelector:name aName:object anObject:) method because the compiler would yell at me for not defining my selector function with an @objc prefix:

When I would define the selector function with the prefix, the compiler would yell at me to remove it:

@objc can only be used with members of classes, @objc protocols, and concrete extensions of classes

It was confounding, but I found another way to add the observer. You can use addObserverForName(name:object obj:queue:usingBlock block:) and define a block to be executed:

func addKeyboardObservers() {
    NSNotificationCenter
      .defaultCenter()
      .addObserverForName(UIKeyboardWillShowNotification,
                          object: nil,
                          queue: nil) { [weak self] notification in
        self?.keyboardWillShow(notification)
    }

    NSNotificationCenter
      .defaultCenter()
      .addObserverForName(UIKeyboardWillHideNotification,
                          object: nil,
                          queue: nil) { [weak self] notification in
        self?.keyboardWillHide()
    }
}

Defining the default implementation for removeKeyboardObservers was easy enough:

func removeKeyboardObservers() {
    NSNotificationCenter
      .defaultCenter()
      .removeObserver(self,
                      name: UIKeyboardWillShowNotification,
                      object: nil)
        
    NSNotificationCenter
      .defaultCenter()
      .removeObserver(self,
                      name: UIKeyboardWillHideNotification,
                      object: nil)
}

Now I needed a function to extract the keyboard height and animation duration information from a keyboard notification so I have some values to work with I created a typealias tuple to store the information in for readability:

typealias KeyboardHeightDuration = (height: CGFloat, duration: Double)

private func getKeyboardInfo(notification: NSNotification) -> KeyboardHeightDuration? {
    guard let userInfo = notification.userInfo else { return nil }
    guard let rect = userInfo[UIKeyboardFrameEndUserInfoKey]?.CGRectValue() else { return nil }
    guard let duration = userInfo[UIKeyboardAnimationDurationUserInfoKey]?.doubleValue else { return nil }
    return (rect.height, duration)
}

Then I defined keyboardWillShow(notification:) that was called in my observer block. This is where we extract the keyboard height and animation duration with getKeyboardInfo(notification:) and pass it to a function that animates the constraints with the information:

private func keyboardWillShow(notification: NSNotification) {
    guard let info = getKeyboardInfo(notification) else { return }
    animateConstraints(info.height, duration: info.duration)
}

Define keyboardWillHide to handle animating constraints when the keyboard is hidden:

private func keyboardWillHide() {
    guard let info = getKeyboardInfo(notification) else { return }
    animateConstraints(0, duration: info.duration)
}

And finally, a function to handle animating the constraints’ constant value with the values of the keyboard height and animation duration.

private func animateConstraints(constant: CGFloat, duration: Double) {
    layoutConstraintsForKeyboard.forEach { c in
        c.constant = constant
    }
    UIView.animateWithDuration(duration) {
        self.view.layoutIfNeeded()
    }
}

As you can see, the protocol is fairly lightweight and straightforward and can be easily implemented into any view controller whose views have been laid out with AutoLayouts.

Implement it!

To implement, create an outlet for the layout constraints to be modified, implement addKeyboardObservers in viewWillAppear(animated:), implement removeKeyboardObservers in viewWillDisappear(animated:), return an array of layout constraints to be modified in layoutConstraintsForKeyboard:

final class NiceViewController: UIViewController {
    @IBOutlet weak var scrollViewBottomConstraint: NSLayoutConstraint!

    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)
        addKeyboardObservers()
    }

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

extension NiceViewController: KeyboardAvoidable {
  var layoutConstraintsForKeyboard: [NSLayoutConstraint] {
    return [scrollViewBottomConstraint]
  }
}

What about tab bars?

I did run into an issue in a project I worked on after creating this protocol. What happens if the view controller is inside a UITabBarController? The constraint constant is modified to the height of the keyboard without taking into account the height of the tab bar. This leaves a space between the content and the keyboard that is the size of the tab bar. The fix was pretty simple and does not affect original implementations.

I modified the protocol to subtract the height of the tab bar from the value the layout constraint constant will be modified by.

private func keyboardWillShow(notification: NSNotification) {
    guard var info = getKeyboardInfo(notification) else { return }
      if let tabBarHeight = tabBarController?.tabBar.frame.height {
        info.height -= tabBarHeight
      }
    animateConstraints(info.height, duration: info.duration)
}

Using a protocol to handle the complications of avoiding UI elements being covered up by the software keyboard allows me to keep my view controllers lean and avoid a lot of duplicated code. This protocol could be made more flexible by adding another var in the protocol definition that allows users to return a custom contstant to modify the constraint constant with rather than using the keyboard height. For now, the implementation is flexible enough for my needs.

How do you handle the need to avoid covering UI elements when the software keyboard is shown?

Get the code

Here is the Gist for the full protocol:

Feel free to offer suggestions by commenting on the Gist here

Leave a Comment

Your email address will not be published. Required fields are marked *