Avoiding the keyboard on UITextField focus
A couple of posts ago I was writing about handling the Next button automatically. In this post I’d like to write about avoiding the keyboard automatically, in a manner that provides both a good user experience and a good developer experience.
Most apps have some sort of form that requires to be filled, even if just a login/register, if not several. As a user, having the keyboard cover the text field I'm about to fill makes me sad; it's a poor user experience. As developers, we'd like to solve this as easily as possible and have the solution as reusable as possible.
What does a good user experience mean?
- The focused
UITextField
is brought above the keyboard on focus. - The focused
UITextField
is ”sent back” on dismiss.
What does a good developer experience mean? Everything should happen as automatically as possible, so we’ll go with a protocol once again. What does this protocol need to encapsulate?
- Observing keyboard will show/hide notifications.
- On keyboard appearance, it needs to modify the
scrollView.contentInset
andscrollView.contentOffset
in a way that brings theUITextField
right above the keyboard. - On keyboard disappearance, it needs to reset the inset and offset to previous values.
With this in mind, let’s build our protocol:
protocol KeyboardListener: AnyObject { // 1
var scrollView: UIScrollView { get } // 2
var contentOffsetPreKeyboardDisplay: CGPoint? { get set } // 3
var contentInsetPreKeyboardDisplay: UIEdgeInsets? { get set } // 4
func keyboardChanged(with notification: Notification) // 5
}
We need to constrain this protocol to be conformed to only by classes (1) because we’ll need to modify the two preKeyboard
properties (3, 4) — we’ll use them to know how to revert the scrollView
’s inset and offset on keyboard dismissal — and we’ll most likely implement this in a UIViewController
anyway.
The protocol also needs to have a scrollView
(2), otherwise this isn’t really … feasible (I guess it could be doable). Lastly, we need the method that will handle everything (5), but it just acts as a proxy for two helpers that we’ll implement in just a bit:
extension KeyboardListener {
func keyboardChanged(with notification: Notification) {
guard
notification.name == UIResponder.keyboardWillShowNotification,
let rawFrameEnd = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey],
let frameEnd = rawFrameEnd as? CGRect,
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval
else {
resetScrollView() // 1
return
}
if let currentTextField = UIResponder.current as? UITextField {
updateContentOffsetOnTextFieldFocus(currentTextField, bottomCoveredArea: frame.height) // 2
}
scrollView.contentInset.bottom += frameEnd.height // 3
}
}
If the notification is not for willShow
, or we can not parse the notification’s userInfo
, bail out and reset the scrollView
. If it is, increase the bottom inset by the keyboard’s height (3). As for (2), we find the current first responder with a little trick to call updateContentOffsetOnTextFieldFocus(_:bottomCoveredArea:)
with, but we could also call it from our delegate’s textFieldShouldBeginEditing(_:)
.
The first helper will update our two preKeyboard
properties:
extension KeyboardListener where Self: UIViewController { // 1
func keyboardChanged(with notification: Notification) {
// [...]
}
func updateContentOffsetOnTextFieldFocus(_ textField: UITextField, bottomCoveredArea: CGFloat) {
let projectedKeyboardY = view.window!.frame.minY - bottomCoveredArea // 2
if contentInsetPreKeyboardDisplay == nil { // 3
contentInsetPreKeyboardDisplay = scrollView.contentInset
}
if contentOffsetPreKeyboardDisplay == nil { // 4
contentOffsetPreKeyboardDisplay = scrollView.contentOffset
}
let textFieldFrameInWindow = view.window!.convert(textField.frame,
from: textField.superview) // 5
let bottomLimit = textFieldFrameInWindow.maxY + 10 // 6
guard bottomLimit > projectedKeyboardY else { return } // 7
let delta = projectedKeyboardY - bottomLimit // 8
let newOffset = CGPoint(x: scrollView.contentOffset.x,
y: scrollView.contentOffset.y - delta) // 9
scrollView.setContentOffset(newOffset, animated: true) // 10
}
}
We will now to update the protocol extension with a Self: UIViewController
constraint (1), because we’ll need access to the window. This shouldn’t be an inconvenience, because this protocol will be most likely used by UIViewController
s, but another approach would be to replace all the view.window
occurrences with UIApplication.shared.keyWindow
or a variation of UIApplication.shared.windows[yourIndex]
, in case you have a complex hierarchy.
We then calculate the minY
for the keyboard (2) — we use a parameter for those cases where we have a custom inputView
and we’ll call this from textFieldShouldBeginEditing(_:)
, for example. We then check if our preKeyboard
properties are nil
and if they are, we assign the current values from the scrollView
(3, 4); they might not be nil
if we changed them prior to calling this method.
We then convert the textField
’s maxY
in the window’s coordinates (5) and add 10
to it (6), so we have a small padding between the field and the keyboard. If the bottomLimit
is above the keyboard’s minY
, do nothing, because the textField
is already fully visible (7). If the bottomLimit
is below the keyboard’s minY
, calculate the difference between them (8) so we know how much to scroll the scrollView
(9, 10) so that the textField
will be visible.
The second helper resets our scrollView
back to the initial values:
extension KeyboardListener where Self: UIViewController {
func keyboardChanged(with notification: Notification) {
// [...]
}
func updateContentOffsetOnTextFieldFocus(_ textField: UITextField, bottomCoveredArea: CGFloat) {
// [...]
}
func resetScrollView() {
guard // 1
let originalInsets = contentInsetPreKeyboardDisplay,
let originalOffset = contentOffsetPreKeyboardDisplay
else { return }
scrollView.contentInset = originalInsets // 2
scrollView.setContentOffset(originalOffset, animated: true) // 3
contentInsetPreKeyboardDisplay = nil // 4
contentOffsetPreKeyboardDisplay = nil // 5
}
}
If we have no original insets/offset, do nothing; for example, a hardware keyboard is used (1). If we do, we reset the scrollView
to its original, pre-keyboard values (2, 3) and nil
-out the preKeyboard
properties (4, 5).
Using this may vary depending on your needs, but the usual scenario would go like this:
final class FormViewController: UIViewController, KeyboardListener {
let scrollView = UIScrollView()
/* Or if you have a tableView:
private let tableView = UITableView()
var scrollView: UIScrollView {
return tableView
}
*/
// [...]
override func viewDidLoad() {
super.videDidLoad()
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: nil) { [weak self] notification in
self?.keyboardChanged(with: notification)
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: nil) { [weak self] notification in
self?.keyboardChanged(with: notification)
}
// And that's it!
}
// [...]
}
This was a long post, but we now have a nice ”keep the text field above the keyboard” logic and if we implement all of this alongside the automatic Next button handling, it will be like magic for our users.
Check out this post about slightly automating this even further, by implementing the Broadcaster/Listener system and moving the observers in the Broadcaster
itself. We wouldn’t need to add observers in our view controllers anymore, we’d just have to call Broadcaster.shared.addListener(self)
.
As usual, let me know if there’s anything that can be improved @rolandleth.
You can find more articles like this on my blog, or you can subscribe to my monthly newsletter. Originally published at https://rolandleth.com.