// // 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 adjustViewForKeyboard(withKeyboardFrameEvent:)... } /// 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 adjustViewForKeyboard(withKeyboardFrameEvent:)... } } /// Tests submitting a keyboard frame event. 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 adjustViewForKeyboard(withKeyboardFrameEvent:)... } /// 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 scroll /// view. /// /// This method correctly handles the case where the view doesn't cover the entire /// screen. /// /// - 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. private func bottomInset(from keyboardFrame: CGRect?) -> CGFloat? { guard let keyboardFrame = keyboardFrame, let hostViewController = delegate?.hostViewController, let view = hostViewController.view, // UIApplication.shared.keyWindow is nil when unit tests are executing, and // view.window is nil outside of unit tests in the case when a view is being // pushed. let window = isUnitTest ? view.window : UIApplication.shared.keyWindow else { return nil } // The frame of the view in the window's coordinate space. let viewFrameInWindow = window.convert(view.frame, from: view.superview) // The intersection of the keyboard's frame with the frame of the view in the // window's coordinate space. let keyboardViewIntersectionFrameInWindow = viewFrameInWindow.intersection(keyboardFrame) // The intersection of the keyboard's frame with the frame of the view in the // view's coordinate space. let keyboardViewIntersectionFrameInView = window.convert(keyboardViewIntersectionFrameInWindow, to: view) // The height of the region of the keyboard that overlaps the view. let overlappingKeyboardHeight = keyboardViewIntersectionFrameInView.height // The view's safe area bottom inset. let safeAreaBottomInset = hostViewController.view.safeAreaInsets.bottom // The view'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 view. let keyboardHeightOverlappingView = max(0, overlappingKeyboardHeight - baseSafeAreaBottomInset) return keyboardHeightOverlappingView } }