Intro
It's a pretty common thing we have to work with during the development process. Conceptually, if any view inside UIScrollView
gets covered by the system keyboard, you have to follow a few steps to make it work perfectly:
- Write a callback function (selector) that will handle keyboard appearance
- Write a callback function (selector) that will handle keyboard dissapearance
- Write function to subscribe for keyboard system events (appearance and dissapearance) using written selectors (from step 1 and step 2)
- Write function to unsubscribe from keyboard system events
- Add subscription (step 3) and unsubscription (step 4) methods in appropriate view-life-cycle methods
Let's dive into
Disclaimer
The most important part to answer clearly is providing conception that you can adapt in your real code environment with any architecture you have. So, next steps assume that we have some custom UIViewController
that has access to some custom UIView
with UIScrollView
and your accidentally covered UI (any UIView
) inside UIScrollView
.
How get we know about keyboard events?
NotificationCenter
is a dispatch mechanism that allows us to subscribe for any information we need as observers and, luckily, for keyboard events too. So, our custom UIViewController
will be an observer for keyboard events from NotificationCenter
. NotificationCenter
uses notifications (NSNotification
) as a way of broadcasting that contains some userInfo
with useful information inside. That's what we'll work with. So, let's dive into implementation finally!
Implementation
Step 1
We need to write a selector that will be used for handling income notification that will come each time the keyboard appears to fetch all the neccesary data from event:
import UIKit
import os
final class Controller: UIViewController {
// let's assume it's your custom view
// as instance of class `View` with
// `UIScrollView` inside and your covered UI as well:
private let body: View
// step 1:
@objc
private func willShowKeyboard(from notification: NSNotification) {
// #1
guard let screen = notification.object as? UIScreen else {
Logger().error("\(#function) invalid screen object in notification")
return
}
// #2
guard let userInfo = notification.userInfo else {
Logger().error("\(#function) no user info in notification")
return
}
// #3
guard let frameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else {
Logger().error("\(#function) no keyboard frame in user info from notification")
return
}
// #4
let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey]
let curve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey]
guard
let animationDuration = duration as? Double,
let animationCurve = curve as? UInt
else {
Logger().error("\(#function) no correct animation duration or curve in user info from notification")
return
}
// #5
let viewCoordinateSpace: UICoordinateSpace = self.body
let globalCoordinatesSpace: UICoordinateSpace = screen.coordinateSpace
let keyboardFrame = globalCoordinateSpace.convert(frame.cgRectValue, to: viewCoordinateSpace)
// #6
let textFieldFrame = self.body.textField.frame
let keyboardMinY = keyboardFrame.origin.y
// let's assume it's a normal space between
// keyboard and the bottom edge of text field:
let keyboardTopMargin: CGFloat = 20
let haveToBeVisibleY = textFieldFrame.origin.y + textFieldFrame.height + keyboardTopMargin
let yDifference = haveToBeVisibleY - keyboardMinY
// is bottom edge of the text field visually lower than a top edge of the keyboard?
if yDifference > .zero {
// #7
let options: UIView.AnimationOptions = .init(rawValue: animationCurve << 16)
// #8
let offsetBefore = self.body.scrollView.contentOffset
let newOffset = CGPoint(x: offsetBefore.x, y: offsetBefore.y + offset)
// #9
UIView.animate(withDuration: animationDuration, delay: .zero, options: options) {
self.scrollView.contentOffset = newOffset
}
}
}
}
Let me try explain the code upper with a few more details below:
- We're trying to retrieve object as an
UIScreen
instance that later we use in step #
- As any usual notification contains of
userInfo
, we're trying to retrieve a hasmap of useful information
- We're retrieving a frame of the keyboard after animation will be completed. If you want to consider a frame of the keyboard at the start position before animation performing, check a key by using
UIResponder.keyboardFrameBeginUserInfoKey
.
- Then we retrieve duration and curve of the future keyboard appearance animation and then make type casting for them to adopt for our usage
- One of the most important parts why I decided to write this answer: keyboard's frame is not always in the same coordinate system comparing to your UI's coordinate system. That is why we did step #1 in the code above. We take both coordinate systems and convert keyboard's coordinate system into our system to get a valid keyboard frame to avoid calculation errors!
- Then we want to realize whether the bottom edge of our UI (text field, for instance but it can be anything we want) is visually lower than the top edge of the keyboard including keyboard's top margin (you may avoid top margin or set your own appropriate value to improve UI visually). For this reason, we calculate max Y of text field (including keyboard's top margin space) and min Y of the keyboard's frame because vertical axis system starts from the top of the screen due to the core logic. And, if we the max Y of the text field is bigger than the min Y of the keyboard, than we need to add inset for our scroll view to scroll it up for an appropriate Y points (
yDifference
).
- Based on step 4, we can add content offset for scroll view with the same animation and at the same time as a keyboard will appear. That's why retrieved animation duration and animation curve to immitate the keyboard's system animation. But the curve we've got from user info is a raw 32-bit unsigned integer value in fact, so to adopt our raw curve value to
UIView.AnimationOptions
's value we need to shift left our raw value by 16 bits to avoid zeroes on the left of the range of bits in raw value.
- Alright, all we gotta do is just to calculate a new offset based on current offset of scroll view. We just add a new offset by Y axis to the current one.
- And inside the block with all necessary and correct options we perform setting a new offset for scroll view animationally smoothly as user expected by default.
Don't worry, if you read it, all the next steps will be much easier.
Step 2
As we've written a function that handles keyboard appearance, we also need to write a function that scrolls the content back each time the keyboard will dissapear:
import UIKit
import os
final class Controller: UIViewController {
// some code from step 1
@objc
private func willHideKeyboard(from notification: NSNotification) {
// or any other value such a previous value before the keyboard has appeared.
self.body.scrollView.contentOffset = .zero
}
}
Step 3
Alright, since we've written the core logic when the keyboard appears and dissapears, all we gotta do is just add these selectors to the notification center:
import UIKit
import os
final class Controller: UIVIewController {
/*
some code from step 1, 2
*/
private func subscribeForKeyboardEvents() {
self.subscribeForKeyboardAppearance()
self.subscribeForKeyboardDissapearance()
}
private func subscribeForKeyboardAppearance() {
let selector = #selector(self.willShowKeyboard(from:))
let name = UIResponder.keyboardWillShowNotification
NotificationCenter
.default
.addObserver(self, selector: selector, name: name, object: nil)
}
private func subscribeForKeyboardDissapearance() {
let selector = #selector(self.willHideKeyboard(from:))
let name = UIResponder.keyboardWillHideNotification
NotificationCenter
.default
.addObserver(self, selector: selector, name: name, object: nil)
}
}
Step 4
And if the view controller and its view will be destroyed from memory, it would be great to consider prepared methods to unsubscribe from keyboard events in a similar way as we did in the previous step but vice-versa:
// some code from steps 1, 2 and 3
private func unsubscribeFromKeyboardEvents() {
self.unsubscribeFromKeyboardAppearance()
self.unsubscribeFromKeyboardDisappearance()
}
private func unsubscribeFromKeyboardAppearance() {
let name = UIResponder.keyboardWillShowNotification
NotificationCenter
.default
.removeObserver(self, name: name, object: nil)
}
private func unsubscribeFromKeyboardDisappearance() {
let name = UIResponder.keyboardWillHideNotification
NotificationCenter
.default
.removeObserver(self, name: name, object: nil)
}
Step 5
We just call functions from steps 3 and 4 in appropriate view-life-cycle methods as below:
final class Controller: UIViewController {
// code from previous steps
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.subscribeForKeyboardEvents()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.unsubscribeFromKeyboardEvents()
}
}
Get interested?
Read more in the official documentation by Apple: