Posted 1/30/2018.
Wherever users can enter text in our apps, we usually need to respond to keyboard events. Adjusting our app's UI when the keyboard is shown and dismissed is necessary to maintain a good user experience. But what if we have multiple screens in our app that need to respond to keyboard events?
In this tutorial we'll write a reusable KeyboardObserver
class that allows view controllers to easily respond to keyboard events. It will abstract away NotificationCenter
and define a simple delegate protocol that view controllers can implement.
Let's start with the public API of our keyboard observer, the delegate protocol.
protocol KeyboardObserverDelegate: class {
func keyboardObserver(_ keyboardObserver: KeyboardObserver, didShowKeyboardWithAttributes attributes: KeyboardPresentationAttributes)
func keyboardObserver(_ keyboardObserver: KeyboardObserver, didHideKeyboardWithAttributes attributes: KeyboardPresentationAttributes)
}
This is all our view controller will need to implement. Our keyboard observer class will receive the keyboard event notifications from NotificationCenter, which include a set of presentation attributes such as the beginning and ending frame of the keyboard, the duration of the presentation animation, and the animation curve used in the animation.
We haven't written the KeyboardPresentationAttributes
struct that is passed back in both functions yet, so let's do that now:
struct KeyboardPresentationAttributes {
let beginFrame: CGRect
let endFrame: CGRect
let animationDuration: Double
let animationOptions: UIViewAnimationOptions
init?(userInfo: [AnyHashable: Any]?) {
guard
let beginFrame = (userInfo?[UIKeyboardFrameBeginUserInfoKey] as AnyObject).cgRectValue,
let endFrame = (userInfo?[UIKeyboardFrameEndUserInfoKey] as AnyObject).cgRectValue,
let animationDuration = (userInfo?[UIKeyboardAnimationDurationUserInfoKey] as AnyObject).doubleValue,
let animationCurve = (userInfo?[UIKeyboardAnimationCurveUserInfoKey] as AnyObject).uintValue
else { return nil }
self.beginFrame = beginFrame
self.endFrame = endFrame
self.animationDuration = animationDuration
self.animationOptions = UIViewAnimationOptions(rawValue: animationCurve << 16)
}
}
The keyboard event notification's attributes are stored in its userInfo
, which we will pass into the initializer here. One important note is that the attributes dictionary contains the integer value for a UIViewAnimationCurve
, but UIView animations require a value of type UIViewAnimationOptions
. Bitshifting the UIViewAnimationCurve
value is currently a reliable way to convert it into UIViewAnimationOptions
.
Now that we have the delegate protocol and a convenient struct for representing the keyboard presentation attributes, we're ready to write the observer class:
class KeyboardObserver {
weak var delegate: KeyboardObserverDelegate?
init(delegate: KeyboardObserverDelegate?) {
self.delegate = delegate
addObservers()
}
private func addObservers() {
NotificationCenter.default.addObserver(self, selector: #selector(toggleKeyboard), name: .UIKeyboardWillShow, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(toggleKeyboard), name: .UIKeyboardWillHide, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func toggleKeyboard(for notification: Notification) {
guard let attributes = KeyboardPresentationAttributes(userInfo: notification.userInfo) else { return }
switch notification.name {
case .UIKeyboardWillShow:
delegate?.keyboardObserver(self, didShowKeyboardWithAttributes: attributes)
case .UIKeyboardWillHide:
delegate?.keyboardObserver(self, didHideKeyboardWithAttributes: attributes)
default:
break
}
}
}
As you can see, it's pretty straightforward. The observer is initialized with a delegate, adds itself as a NotificationCenter
observer for the keyboard event notifications, and removes itself as an observer when it is deinitialized. Both notifications use the toggleKeyboard
selector, which creates a KeyboardPresentationAttributes
value from the notification's userInfo
dictionary and switches on the notification's name to determine which delegate method to call.
All that is left now is to add an instance of KeyboardObserver
to a view controller and implement the KeyboardObserverDelegate
protocol. First, let's look at the view controller setup:
class TextEntryViewController: UIViewController {
@IBOutlet weak var textView: UITextView!
@IBOutlet weak var textViewBottomConstraint: NSLayoutConstraint!
var keyboardObserver: KeyboardObserver?
let textViewPadding: CGFloat = 8
override func viewDidLoad() {
super.viewDidLoad()
configureTextView()
keyboardObserver = KeyboardObserver(delegate: self)
}
func configureTextView() {
textView.layer.cornerRadius = 8
textView.layer.borderWidth = 1
textView.layer.borderColor = UIColor.black.cgColor
}
}
All this view controller has is a text view constrained to the edges of the main view. We have an outlet for the bottom constraint, which we'll use to adjust the text view's height when the keyboard is shown and dismissed.
Here we've also added an instance of KeyboardObserver
, which is set in viewDidLoad
passing in self
as the delegate. Let's now implement the delegate protocol:
extension TextEntryViewController: KeyboardObserverDelegate {
func toggleKeyboard(with attributes: KeyboardPresentationAttributes) {
UIView.animate(withDuration: attributes.animationDuration, delay: 0, options: attributes.animationOptions, animations: {
self.textViewBottomConstraint.constant = attributes.endFrame.size.height + self.textViewPadding
self.view.layoutIfNeeded()
}, completion: nil)
}
func keyboardObserver(_ keyboardObserver: KeyboardObserver, didShowKeyboardWithAttributes attributes: KeyboardPresentationAttributes) {
toggleKeyboard(with: attributes)
}
func keyboardObserver(_ keyboardObserver: KeyboardObserver, didHideKeyboardWithAttributes attributes: KeyboardPresentationAttributes) {
toggleKeyboard(with: attributes)
}
}
Both delegate method implementations call toggleKeyboard
, which simply uses the keyboard presentation attributes to animate the textViewBottomConstraint
.
Responding to keyboard events is a common requirement, and as engineers we are always looking for ways to centralize and reuse common patterns. Aside from allowing view controllers to add handling of keyboard notifications more easily, centralizing this logic in a separate class makes it easier to test. For example, as a next step we might use dependency injection to test the NotificationCenter
logic, and doing this in one place is much better than testing this logic separately in every view controller that allows text input.