319 lines
15 KiB
Swift
319 lines
15 KiB
Swift
//
|
|
// KeyboardObserver.swift
|
|
// ScrollingContentViewController
|
|
//
|
|
// Created by Drew Olbrich on 12/25/18.
|
|
// Copyright 2019 Oath Inc.
|
|
//
|
|
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
/// An object that responds to changes in the keyboard's visibility.
|
|
///
|
|
/// When the keyboard is presented or dismissed, or when the size of the keyboard
|
|
/// changes, `KeyboardObserver` compensates by calling
|
|
/// `KeyboardObservering.adjustViewForKeyboard(withBottomInset:)` method after a
|
|
/// short delay. See `ScrollViewFilter` for details.
|
|
internal class KeyboardObserver: NSObject {
|
|
|
|
// See https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html#//apple_ref/doc/uid/TP40009542-CH5-SW3
|
|
|
|
private weak var delegate: KeyboardObservering?
|
|
|
|
private weak var scrollViewFilter: ScrollViewFilter?
|
|
|
|
/// The duration of the animation of the change to the container view's bottom inset.
|
|
private let bottomInsetAnimationDuration: TimeInterval = 0.5
|
|
|
|
/// `true` if `viewSafeAreaInsetsDidChange` is executing. Used to avoid nested
|
|
/// calls to `updateForCurrentKeyboardVisibility`.
|
|
private var isAdjustingViewForKeyboardFrameEvent = false
|
|
|
|
init(scrollViewFilter: ScrollViewFilter, delegate: KeyboardObservering) {
|
|
super.init()
|
|
|
|
self.scrollViewFilter = scrollViewFilter
|
|
self.delegate = delegate
|
|
|
|
scrollViewFilter.keyboardDelegate = self
|
|
|
|
KeyboardNotificationManager.shared.addKeyboardNotificationObserver(self)
|
|
}
|
|
|
|
deinit {
|
|
KeyboardNotificationManager.shared.removeKeyboardNotificationObserver(self)
|
|
}
|
|
|
|
/// Returns `true` if filtering is suspended.
|
|
var isSuspended: Bool {
|
|
return scrollViewFilter?.isSuspended == true
|
|
}
|
|
|
|
/// Suspends filtering of changes to the keyboard's frame.
|
|
func suspend() {
|
|
scrollViewFilter?.suspend()
|
|
}
|
|
|
|
/// Resumes filtering of changes to the keyboard's frame.
|
|
func resume() {
|
|
scrollViewFilter?.resume()
|
|
}
|
|
|
|
/// Responds to changes in the view controller's safe area insets.
|
|
func viewSafeAreaInsetsDidChange() {
|
|
guard !isAdjustingViewForKeyboardFrameEvent else {
|
|
// Ignore safe area inset changes that result from self-induced changes to
|
|
// `additionalSafeAreaInsets` (as opposed to those generated by UIKit itself),
|
|
// which would result in nested calls to
|
|
// `scrollViewFilter(_:adjustViewForKeyboardFrameEvent:)`. These changes to
|
|
// `additionalSafeAreaInsets` are occurring in respond to changes that we are
|
|
// intentionally making to the scroll view's bottom inset, so there is no need to
|
|
// react to them again. Doing so appears to be harmless (through experimentation),
|
|
// but is hard to reason about.
|
|
return
|
|
}
|
|
|
|
updateForCurrentKeyboardVisibility()
|
|
}
|
|
|
|
/// Updates the view controller to compensate for the appearance or disappearance of
|
|
/// the keyboard or changes to the keyboard's size in response to a notification.
|
|
private func updateForKeyboardVisibilityNotification(_ notification: Notification) {
|
|
guard let keyboardFrameEvent = self.keyboardFrameEvent(from: notification),
|
|
let scrollView = delegate?.scrollView,
|
|
let scrollViewFilter = scrollViewFilter else {
|
|
return
|
|
}
|
|
|
|
// Suppress unwanted text field animation generated by UIKit.
|
|
suppressTextFieldTextAnimation()
|
|
|
|
// Instead of responding to the change in the keyboard frame by resizing the view
|
|
// immediately, filter sequences of changes so that only the final change is
|
|
// handled. This avoids unwanted animation.
|
|
scrollViewFilter.submitKeyboardFrameEvent(keyboardFrameEvent)
|
|
|
|
if notification.name == UIResponder.keyboardWillHideNotification
|
|
&& delegate?.shouldResizeContentViewForKeyboard == true
|
|
&& scrollView.keyboardDismissMode != .none
|
|
&& scrollView.isTracking {
|
|
// If the keyboard is being dismissed by way of a drag gesture and the content view
|
|
// is resizable, respond to the change in the keyboard's frame immediately, without
|
|
// the temporal filtering normally provided by KeyboardAdjustmentFilter, so the
|
|
// animation of the scroll view's frame change is handled within UIKit's animation
|
|
// block that wraps the keyboardWillHide notification. This results in more
|
|
// pleasing animation. The alternative, waiting until the KeyboardAdjustmentFilter
|
|
// timer fired, would result in an awkward jump of the scroll view's contents as
|
|
// the content view area was resized.
|
|
scrollViewFilter.flush()
|
|
} else if keyboardFrameEvent.isResultOfNavigationControllerTransition {
|
|
// If the keyboard is being presented because of a UINavigationController
|
|
// transition, flush the keyboard frame change filter immediately, without
|
|
// animation. If, instead, handling of the keyboard frame change is deferred, then
|
|
// if the user pops a view controller that has no visible keyboard to a view that
|
|
// has a visible keyboard, the layout of the view would change size halfway through
|
|
// the transition.
|
|
UIView.performWithoutAnimation {
|
|
scrollViewFilter.flush()
|
|
}
|
|
}
|
|
|
|
// Continues in scrollViewFilter(_:adjustViewForKeyboardFrameEvent:)...
|
|
}
|
|
|
|
/// Updates the view controller to compensate for the current state of the keyboard.
|
|
///
|
|
/// This method handles the case where a navigation controller pushes a view
|
|
/// controller while the keyboard is already visible, in which case UIKit will not
|
|
/// generate a notification. In this case, the last notification captured by
|
|
/// `KeyboardNotificationManager` is repeated.
|
|
private func updateForCurrentKeyboardVisibility() {
|
|
if let lastNotification = KeyboardNotificationManager.shared.lastNotification,
|
|
let keyboardFrameEvent = keyboardFrameEvent(from: lastNotification),
|
|
let scrollViewFilter = scrollViewFilter {
|
|
scrollViewFilter.submitKeyboardFrameEvent(keyboardFrameEvent)
|
|
|
|
// The keyboard frame event filter is flushed when it isn't suspended. Typically,
|
|
// at this point, it will be suspended during device orientation changes, and if
|
|
// the filter was flushed, jerky animation would result.
|
|
if !scrollViewFilter.isSuspended {
|
|
scrollViewFilter.flush()
|
|
}
|
|
|
|
// Continues in scrollViewFilter(_:adjustViewForKeyboardFrameEvent:)...
|
|
}
|
|
}
|
|
|
|
/// Tests submitting a keyboard frame event. This method is used by unit tests only.
|
|
internal func testKeyboardFrameEvent(_ keyboardFrameEvent: KeyboardFrameEvent) {
|
|
// This method is intended for use in unit tests only.
|
|
assert(isUnitTest)
|
|
|
|
guard let scrollViewFilter = scrollViewFilter else {
|
|
return
|
|
}
|
|
|
|
scrollViewFilter.submitKeyboardFrameEvent(keyboardFrameEvent)
|
|
scrollViewFilter.flush()
|
|
|
|
// Continues in scrollViewFilter(_:adjustViewForKeyboardFrameEvent:)...
|
|
}
|
|
|
|
/// Suppresses unwanted text field text animation.
|
|
///
|
|
/// If the user taps a sequence of text fields, unwanted animation in the position
|
|
/// of the text within the text fields may occur. This method suppresses this
|
|
/// behavior by calling `layoutIfNeeded` within a `performWithoutAnimation` closure.
|
|
///
|
|
/// It appears that UIKit posts `UIResponder` keyboard notifications after updating
|
|
/// text fields within animation blocks.
|
|
private func suppressTextFieldTextAnimation() {
|
|
UIView.performWithoutAnimation {
|
|
delegate?.contentView?.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
/// Returns a keyboard frame event, given a notification.
|
|
///
|
|
/// - Parameter notification: The `UIResponder` keyboard notification.
|
|
/// - Returns: The keyboard's frame.
|
|
private func keyboardFrameEvent(from notification: Notification) -> KeyboardFrameEvent? {
|
|
guard let userInfo = notification.userInfo,
|
|
let keyboardFrameEndUserInfoValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
|
|
let keyboardAnimationDurationNumber = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber,
|
|
let window = UIApplication.shared.keyWindow
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let keyboardFrame = keyboardFrameEndUserInfoValue.cgRectValue
|
|
let keyboardAnimationDuration = keyboardAnimationDurationNumber.doubleValue as TimeInterval
|
|
|
|
// From https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/KeyboardManagement/KeyboardManagement.html#//apple_ref/doc/uid/TP40009542-CH5-SW3
|
|
// "Note: The rectangle contained in the UIKeyboardFrameBeginUserInfoKey and
|
|
// UIKeyboardFrameEndUserInfoKey properties of the userInfo dictionary should be
|
|
// used only for the size information it contains. Do not use the origin of the
|
|
// rectangle (which is always {0.0, 0.0}) in rectangle-intersection operations.
|
|
// Because the keyboard is animated into position, the actual bounding rectangle of
|
|
// the keyboard changes over time."
|
|
|
|
switch notification.name {
|
|
case UIResponder.keyboardWillHideNotification:
|
|
return KeyboardFrameEvent(
|
|
keyboardFrame: CGRect(x: 0, y: window.bounds.height, width: keyboardFrame.size.width, height: 0),
|
|
duration: keyboardAnimationDuration)
|
|
case UIResponder.keyboardWillShowNotification:
|
|
return KeyboardFrameEvent(
|
|
keyboardFrame: CGRect(x: 0, y: window.bounds.height - keyboardFrame.size.height, width: keyboardFrame.size.width, height: keyboardFrame.size.height),
|
|
duration: keyboardAnimationDuration)
|
|
default:
|
|
assertionFailure("Unexpected notification type")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension KeyboardObserver: KeyboardNotificationObserving {
|
|
|
|
func didReceiveKeyboardNotification(_ notification: Notification) {
|
|
updateForKeyboardVisibilityNotification(notification)
|
|
}
|
|
|
|
}
|
|
|
|
extension KeyboardObserver: ScrollViewFilterKeyboardDelegate {
|
|
|
|
func scrollViewFilter(_ scrollViewFilter: ScrollViewFilter, adjustViewForKeyboardFrameEvent keyboardFrameEvent: KeyboardFrameEvent) {
|
|
isAdjustingViewForKeyboardFrameEvent = true
|
|
defer {
|
|
isAdjustingViewForKeyboardFrameEvent = false
|
|
}
|
|
|
|
guard let bottomInset = self.bottomInset(from: keyboardFrameEvent.keyboardFrame) else {
|
|
return
|
|
}
|
|
|
|
// Disable animation for UINavigationController push transitions. Otherwise, if the
|
|
// keyboard remains visible during the transition, this may result in unwanted
|
|
// animation of the size of the view above the keyboard as the new view controller
|
|
// is presented.
|
|
let animated = keyboardFrameEvent.isResultOfNavigationControllerTransition == false
|
|
|
|
func animations() {
|
|
self.delegate?.adjustViewForKeyboard(withBottomInset: bottomInset)
|
|
self.delegate?.hostViewController?.view.layoutIfNeeded()
|
|
}
|
|
|
|
func completion(_ finished: Bool) {
|
|
// Do nothing.
|
|
}
|
|
|
|
if animated {
|
|
UIView.animate(withDuration: bottomInsetAnimationDuration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: [], animations: animations, completion: completion)
|
|
} else {
|
|
animations()
|
|
completion(true)
|
|
}
|
|
|
|
assert(isAdjustingViewForKeyboardFrameEvent)
|
|
}
|
|
|
|
/// Returns the height of portion of the keyboard's frame that overlaps the host
|
|
/// view controller's root view.
|
|
///
|
|
/// This method correctly handles the case where the view controller's root view
|
|
/// does not cover the entire screen, for example if the view controller is composed
|
|
/// within another view controller.
|
|
///
|
|
/// - Parameter notification: The keyboard notification that provides the keyboard's
|
|
/// frame.
|
|
/// - Returns: The height of portion of the keyboard's frame that overlaps the view
|
|
/// controller's root view.
|
|
private func bottomInset(from keyboardFrame: CGRect?) -> CGFloat? {
|
|
guard let keyboardFrame = keyboardFrame,
|
|
let hostViewController = delegate?.hostViewController,
|
|
let rootView = hostViewController.view,
|
|
// UIApplication.shared.keyWindow is nil when unit tests are executing, and
|
|
// rootView is nil outside of unit tests in the case when a view is being pushed.
|
|
let window = isUnitTest ? rootView.window : UIApplication.shared.keyWindow else {
|
|
return nil
|
|
}
|
|
|
|
// The frame of the view controller's root view in the window's coordinate space.
|
|
let rootViewFrameInWindow = window.convert(rootView.frame, from: rootView.superview)
|
|
|
|
// The intersection of the keyboard's frame with the frame of the root view in
|
|
// the window's coordinate space.
|
|
let keyboardViewIntersectionFrameInWindow = rootViewFrameInWindow.intersection(keyboardFrame)
|
|
|
|
// The intersection of the keyboard's frame with the frame of the root view in
|
|
// the root view's coordinate space.
|
|
let keyboardViewIntersectionFrameInRootView = window.convert(keyboardViewIntersectionFrameInWindow, to: rootView)
|
|
|
|
// The height of the region of the keyboard that overlaps the root view.
|
|
let overlappingKeyboardHeight = keyboardViewIntersectionFrameInRootView.height
|
|
|
|
// The root view safe area bottom inset.
|
|
let safeAreaBottomInset = rootView.safeAreaInsets.bottom
|
|
|
|
// The host view controller's additional safe area bottom inset.
|
|
let additionalSafeAreaBottomInset = hostViewController.additionalSafeAreaInsets.bottom
|
|
|
|
// The bottom safe area bottom inset, excluding the additional safe area inset.
|
|
// This is clamped to zero because in the case when another view controller is
|
|
// being popped and the destination view controller has not yet appeared, it seems
|
|
// that the value returned by safeAreaInsets does not take additionalSafeAreaInsets
|
|
// into account, which would otherwise result in a negative number.
|
|
let baseSafeAreaBottomInset = max(0, safeAreaBottomInset - additionalSafeAreaBottomInset)
|
|
|
|
// The height of area of the keyboard's frame that overlaps the root view.
|
|
let keyboardHeightOverlappingRootView = max(0, overlappingKeyboardHeight - baseSafeAreaBottomInset)
|
|
|
|
return keyboardHeightOverlappingRootView
|
|
}
|
|
|
|
}
|