Issue #329
Firstly, to make UIStackView scrollable, embed it inside UIScrollView. Read How to embed UIStackView inside UIScrollView in iOS
It’s best to listen to keyboardWillChangeFrameNotification
as it contains frame changes for Keyboard in different situation like custom keyboard, languages.
Posted immediately prior to a change in the keyboard’s frame.
class KeyboardHandler {
let scrollView: UIScrollView
let stackView: UIStackView
var observer: AnyObject?
var keyboardHeightConstraint: NSLayoutConstraint!
struct Info {
let frame: CGRect
let duration: Double
let animationOptions: UIView.AnimationOptions
}
init(scrollView: UIScrollView, stackView: UIStackView) {
self.scrollView = scrollView
self.stackView = stackView
}
}
To make scrollView scroll beyond its contentSize
, we can change its contentInset.bottom
. Another way is to add a dummy view with certain height to UIStackView
and alter its NSLayoutConstraint
constant
We can’t access self
inside init, so it’s best to have setup function
func setup() {
let space = UIView()
keyboardHeightConstraint = space.heightAnchor.constraint(equalToConstant: 0)
NSLayoutConstraint.on([keyboardHeightConstraint])
stackView.addArrangedSubview(spa
observer = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main,
using: { [weak self] notification in
self?.handle(notification)
}
)
}
Convert Notification
to a convenient Info
struct
func convert(notification: Notification) -> Info? {
guard
let frameValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] NSValue,
let durationotification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
let raw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] NSNumber
else {
return nil
return Info(
frame: frameValue.cgRectValue,
duration: duration.doubleValue,
animationOptions: UIView.AnimationOptions(rawValue: raw.uintValue)
)
}
Then we can compare with UIScreen to check if Keyboard is showing or hiding
func handle(_ notification: Notification) {
guard let info = convert(notification: notification) else {
return
let isHiding = info.frame.origin.y == UIScreen.main.bounds.height
keyboardHeightConstraint.constant = isHiding ? 0 : info.frame.hei
UIView.animate(
withDuration: info.duration,
delay: 0,
options: info.animationOptions,
animations: {
self.scrollView.layoutIfNeeded()
self.moveTextFieldIfNeeded(info: info)
}, completion: nil)
}
To move UITextField
we can use scrollRectToVisible(_:animated:)
but we have little control over how much we want to scroll
This method scrolls the content view so that the area defined by rect is just visible inside the scroll view. If the area is already visible, the method does nothing.
Another way is to check if keyboard overlaps UITextField
. To do that we use convertRect:toView:
with nil
target so it uses window coordinates. Since keyboard frame is always relative to window, we have frames in same coordinate space.
Converts a rectangle from the receiver’s coordinate system to that of another view.
rect: A rectangle specified in the local coordinate system (bounds) of the receiver. view: The view that is the target of the conversion operation. If view is nil, this method instead converts to window base coordinates. Otherwise, both view and the receiver must belong to the same UIWindow object.
func moveTextFieldIfNeeded(info: Info) {
guard let input = stackView.arrangedSubviews
.compactMap({ $0 as? UITextField })
.first(where: { $0.isFirstResponder })
else {
return
let inputFrame = input.convert(input.bounds, to: nil)
if inputFrame.intersects(info.frame) {
scrollView.setContentOffset(CGPoint(x: 0, y: inputFrame.height), animated: true)
} else {
scrollView.setContentOffset(.zero, animated: true)
}
}
Move up the entire view
For simplicity, we can move up the entire view
func move(info: Info) {
let isHiding = info.frame.origin.y == UIScreen.main.bounds.height
let moveUp = CGAffineTransform(translationX: 0, y: -info.frame.height)
switch (view.transform, isHiding) {
case (.identity, false):
view.transform = moveUp
case (moveUp, true):
view.transform = .identity
default:
break
}
}
Prefer willShow and willHide
There ’s an edge case with the above switch on view.transform
and isHiding
with one time verification sms code, which make it into the correct case
handling. It’s safe to just set view.transform depending on show
with willHide
and willShow
import UIKit
class KeyboardHandler {
let view: UIView
var observerForWillShow: AnyObject?
var observerForWillHide: AnyObject?
var keyboardHeightConstraint: NSLayoutConstraint!
struct Info {
let frame: CGRect
let duration: Double
let animationOptions: UIView.AnimationOptions
}
init(view: UIView) {
self.view = view
}
func setup() {
observerForWillShow = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillShowNotification,
object: nil,
queue: .main,
using: { [weak self] notification in
self?.handle(notification, show: true)
}
)
observerForWillHide = NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillHideNotification,
object: nil,
queue: .main,
using: { [weak self] notification in
self?.handle(notification, show: false)
}
)
}
func handle(_ notification: Notification, show: Bool) {
guard let info = convert(notification: notification) else {
return
}
UIView.animate(
withDuration: info.duration,
delay: 0,
options: info.animationOptions,
animations: {
self.move(info: info, show: show)
}, completion: nil)
}
func move(info: Info, show: Bool) {
let moveUp = CGAffineTransform(translationX: 0, y: -info.frame.height)
if show {
view.transform = moveUp
} else {
view.transform = .identity
}
}
func convert(notification: Notification) -> Info? {
guard
let frameValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
let raw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber
else {
return nil
}
return Info(
frame: frameValue.cgRectValue,
duration: duration.doubleValue,
animationOptions: UIView.AnimationOptions(rawValue: raw.uintValue)
)
}
}
Read more
Updated at 2020-07-06 07:09:07