on
Adjust Views To Fit the Keyboard
With a scrollable view — whether that’s a UITableView
, UIScrollView
, or even a UIView
— when the keyboard appears, it takes up space and typically means you have to adjust your view to be dynamically responsive depending on the keyboard’s visibility.
The most common way of handling this is using the NotificationCenter
to respond to the following events:
UIResponder.keyboardWillHideNotification
UIResponder.keyboardWillChangeFrameNotification
This works, but you have to add code in every class that uses a keyboard to handle it. One option proposed by Hacking with Swift is:
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
@objc func adjustForKeyboard(notification: Notification) {
guard let keyboardValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
let keyboardScreenEndFrame = keyboardValue.cgRectValue
let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window)
if notification.name == UIResponder.keyboardWillHideNotification {
yourTextView.contentInset = .zero
} else {
yourTextView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardViewEndFrame.height - view.safeAreaInsets.bottom, right: 0)
}
yourTextView.scrollIndicatorInsets = yourTextView.contentInset
let selectedRange = yourTextView.selectedRange
yourTextView.scrollRangeToVisible(selectedRange)
}
This works brilliantly, and there is nothing wrong with it. However, let’s see if there is another way of handling it.
My preference is to have my UIView
and UIViewController
’s logic as specific as possible to that view and to abstract common logic out where possible. I find that it improves the readability of the code and increases the speed at which you can understand an unfamiliar class.
Introducing NSLayoutConstraint Subclasses
This is where subclassing an NSLayoutConstraint
becomes useful. Placing all of the logic for expanding and contracting based on whether the keyboard is showing or hiding allows us to simply set a constraint and not have to worry about handling the logic for it.
If you use AutoLayout
, then you can simply set the class to be the subclassed variant in your project, as shown below:
Alternatively, you can create the constraint programmatically, although my preference is to use AutoLayout
where appropriate.
The only downside to this approach is that if someone were eventually to replace this constraint with a non-subclassed constraint, it would break the view. With that said, something like that should be found by the developer or, at worst, caught with a good testing practice. I believe the upside to this approach far outweighs the downsides.
Below is my variant of the subclass, which is a modification of one created by Meng To that you are more than welcome to use in your projects:
//
// KeyboardLayoutConstraint.swift
// TemplateProject
//
// Created by Adam Wareing on 12/08/19.
// Licenced under MIT.
//
// Based off: https://raw.githubusercontent.com/MengTo/Spring/master/Spring/KeyboardLayoutConstraint.swift
import UIKit
public class KeyboardLayoutConstraint: NSLayoutConstraint {
/// This offset is added on when the keyboard is collapsed.
var keyboardDownOffset: CGFloat = 0
/// Default's to the offset of the constraint so it can be restored when the keyboard hides
private var offset: CGFloat = 0
/// The current height of the keyboard. 0 if the keyboard isn't shown
private var keyboardVisibleHeight: CGFloat = 0
@available(tvOS, unavailable)
override public func awakeFromNib() {
super.awakeFromNib()
offset = constant
NotificationCenter.default.addObserver(self, selector: #selector(KeyboardLayoutConstraint.keyboardWillShowNotification(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(KeyboardLayoutConstraint.keyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: Notification
@objc
func keyboardWillShowNotification(_ notification: Notification) {
if let userInfo = notification.userInfo {
if let frameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
let frame = frameValue.cgRectValue
keyboardVisibleHeight = frame.size.height
}
self.updateConstant()
switch (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber, userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber) {
case let (.some(duration), .some(curve)):
let options = UIView.AnimationOptions(rawValue: curve.uintValue)
UIView.animate(withDuration: TimeInterval(duration.doubleValue), delay: 0, options: options, animations: {
UIApplication.shared.keyWindow?.layoutIfNeeded()
return
})
default:
break
}
}
}
@objc
func keyboardWillHideNotification(_ notification: NSNotification) {
keyboardVisibleHeight = 0
self.updateConstant()
if let userInfo = notification.userInfo {
switch (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber) {
case let (.some(duration), .some(curve)):
let options = UIView.AnimationOptions(rawValue: curve.uintValue)
UIView.animate(withDuration: TimeInterval(duration.doubleValue), delay: 0, options: options, animations: {
UIApplication.shared.keyWindow?.layoutIfNeeded()
return
})
default:
break
}
}
}
func updateConstant() {
if keyboardVisibleHeight == 0 {
self.constant = offset + keyboardDownOffset
return
}
self.constant = offset + keyboardVisibleHeight
}
}
Reference: Medium