Files
ScrollingContentViewController/Source/KeyboardObserver.swift
T
2019-03-22 06:52:03 -07:00

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
}
}