Files
ScrollingContentViewController/Source/ScrollViewFilter.swift
T
2019-02-06 07:18:08 -08:00

267 lines
11 KiB
Swift

//
// ScrollViewFilter.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/24/19.
// 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 applies a temporal filter to keyboard frame change notifications
/// and `scrollRectToVisible` calls to avoid unwanted animation.
///
/// When a text field becomes the first responder, iOS presents the keyboard. If the
/// user taps another text field, changing the first responder, iOS may adjust the
/// keyboard's height if an input accessory view is specified. Often, these changes
/// will generate a sequence multiple of `keyboardWillShow` notifications, each with
/// different keyboard frame heights.
///
/// As an extreme example, if the user populates a text field by tapping on an
/// AutoFill input accessory view, and this action causes a password text field to
/// automatically become the first responder, one `keyboardWillHide` notifications
/// and two `keyboardWillShow` notifications will be posted within a span of 0.1
/// seconds.
///
/// If `KeyboardObserver` were to respond to each of these notifications
/// individually, this would result in awkward discontinuities in the scroll view
/// animation that accompanies changes to the keyboard's height.
///
/// To work around this issue, `ScrollViewFilter` filters out sequences of
/// notifications that occur within a small time window, acting only on the final
/// assigned keyboard frame in the sequence.
///
/// `ScrollViewFilter` also filters calls to `scrollRectToVisible`. If a text field
/// is the first responder when a device orientation change occurs, UIKit will call
/// `scrollRectToVisible` with the text field's frame at the end of the transition
/// at a time when `adjustedContentInset` hasn't yet been updated to reflect the
/// orientation change. This will result in the view scrolling unnnecessarily, or
/// worse, to a point beyond the legal scrolling extent of the scroll view. As a
/// workaround, `ScrollViewFilter` defers this call for a short period of time until
/// after `adjustedContentInset` has been updated.
///
/// Because `ScrollViewFilter` filters both keyboard notifications and
/// `scrollRectToVisible` calls, it is also able to handle a special case in which
/// the device orientation changes, resulting in a keyboard frame change
/// notification which coincides with a call to `scrollRectToVisible` which is
/// implicitly made by iOS. If only the keyboard notifications were filtered and
/// `scrollRectToVisible` calls were allowed to occur as originally scheduled, the
/// scroll view would awkwardly scroll up and down after the device orientation
/// change.
internal class ScrollViewFilter {
/// Delegate that is notified when a change in the keyboard's frame occurs.
weak var keyboardDelegate: ScrollViewFilterKeyboardDelegate?
/// Delegate that is notified when `scrollRectToVisible` should be executed.
weak var scrollDelegate: ScrollViewFilterScrollDelegate?
/// The delay before calls to `submitKeyboardFrameEvent` or
/// `submitKeyboardFrameEvent` will result in delegate calls. This value was chosen
/// to be slightly larger than the interval between successive keyboard frame
/// notifications that accompany device orientation changes. If a significantly
/// smaller value is chosen, the view will resize erratically during an orientation
/// change.
private let delay: TimeInterval = 0.1
/// The last submitted keyboard event.
private var keyboardFrameEvent: KeyboardFrameEvent?
/// The last submitted scroll rect event.
private var scrollRectEvent: ScrollRectEvent?
/// The timer used to apply the temporal filter.
private var timer: Timer?
/// The time that the timer started.
private var timerStartDate: Date?
/// The timer's time interval.
private var timerTimeInterval: TimeInterval?
/// This property is `true` when the temporal filtering is suspended by calling the
/// `suspend` method.
private(set) var isSuspended = false
/// This property is `true` if the timer was active when `suspend` was last called,
/// or if an attempt was made to start the timer while the filter was suspended.
private var shouldRestartTimerWhenResumed = false
/// The time remaining on the timer when it was suspended. This time interval will
/// be used when filtering is resumed and the timer is restarted.
private var suspendedTimerTimerInterval: TimeInterval = 0
deinit {
cancel()
}
/// Submits a keyboard frame event, which will result in a call to
/// `ScrollViewFilterKeyboardDelegate.scrollViewFilter(_:adjustViewForKeyboardFrameEvent:)`
/// after a short delay.
///
/// - Parameter keyboardFrameEvent: The keyboard frame event to submit.
func submitKeyboardFrameEvent(_ keyboardFrameEvent: KeyboardFrameEvent) {
self.keyboardFrameEvent = keyboardFrameEvent
startTimer(timeInterval: delay)
}
/// Submits a scroll rect event, which will result in a call to
/// `ScrollViewFilterScrollDelegate.scrollViewFilter(_:adjustViewForScrollRectEvent:)`
/// after a short delay.
///
/// - Parameter scrollRectEvent: The scroll rect event to submit.
func submitScrollRectEvent(_ scrollRectEvent: ScrollRectEvent) {
self.scrollRectEvent = scrollRectEvent
startTimer(timeInterval: delay)
}
/// Cancels the filter.
///
/// No delegate calls will be made until new events are submitted.
func cancel() {
invalidate()
shouldRestartTimerWhenResumed = false
suspendedTimerTimerInterval = 0
}
/// Immediately notifies the delegates of any pending events.
///
/// If no events are pending, this method has no effect. If the filter is suspended,
/// no action is taken, but pending events will be acted upon immediately when the
/// filter is resumed.
func flush() {
guard !isSuspended else {
// Fire the timer immediately when it is resumed.
suspendedTimerTimerInterval = 0
return
}
timer?.fire()
}
/// Suspends filtering.
///
/// The filter may be restarted by calling `resume`.
func suspend() {
guard !isSuspended else {
return
}
if timer != nil {
shouldRestartTimerWhenResumed = true
suspendedTimerTimerInterval = remainingTimerTimeInterval
} else {
shouldRestartTimerWhenResumed = false
suspendedTimerTimerInterval = 0
}
isSuspended = true
invalidate()
}
/// Resumes filtering that was suspended earlier.
func resume() {
guard isSuspended else {
return
}
isSuspended = false
if shouldRestartTimerWhenResumed {
shouldRestartTimerWhenResumed = false
startTimer(timeInterval: suspendedTimerTimerInterval)
suspendedTimerTimerInterval = 0
}
}
/// Invalidates the timer.
private func invalidate() {
timer?.invalidate()
timerStartDate = nil
timerTimeInterval = nil
}
/// Starts the timer that filters scroll view updates.
private func startTimer(timeInterval: TimeInterval) {
if isSuspended {
shouldRestartTimerWhenResumed = true
suspendedTimerTimerInterval = max(suspendedTimerTimerInterval, timeInterval)
return
}
// Constrain the remaining time interval so it can't get shorter.
let timeInterval = max(timeInterval, remainingTimerTimeInterval)
// This must be called after remainingTimerTimeInterval is referenced above, or
// else remainingTimerTimeInterval will always return zero.
cancel()
// Don't bother starting the timer if the interval would be zero.
if timeInterval == 0 {
callDelegatesIfNeeded()
return
}
timerStartDate = Date()
// This value must be stored separately because Timer.timeInterval, which ideally
// should be referenced below in remainingTimerTimeInterval, returns 0 for
// non-repeating timers.
self.timerTimeInterval = timeInterval
let timer = Timer(timeInterval: timeInterval, repeats: false, block: { [weak self] (timer: Timer) in
guard let self = self else {
return
}
self.callDelegatesIfNeeded()
// This is intentionally called after callDelegatesIfNeeded, not before, to allow
// for the case where the adjustViewForKeyboardFrameEvent delegate call results in
// a call to submitScrollRectEvent, which will restart the timer but which will
// also be handled immediately in callDelegatesIfNeeded.
self.invalidate()
})
self.timer = timer
// RunLoop.Mode.common must be used instead of the default run loop mode, because
// otherwise the timer will not fire while the scroll view is scrolling, which will
// be the case when the user swipes to dismiss the keyboard when the scroll view's
// keyboardDismissMode is set to interactive, in which case the keyboard frame will
// be adjusted by KeyboardObserver only after an extended delay.
RunLoop.current.add(timer, forMode: .common)
}
/// Calls the keyboard frame delegate and/or the scroll rect delegate, if needed.
private func callDelegatesIfNeeded() {
if let keyboardFrameEvent = keyboardFrameEvent {
self.keyboardFrameEvent = nil
keyboardDelegate?.scrollViewFilter(self, adjustViewForKeyboardFrameEvent: keyboardFrameEvent)
}
if let scrollRectEvent = scrollRectEvent {
// Note: It's possible that the call to adjustViewForKeyboardFrameEvent, above,
// results in a new call to submitScrollRectEvent which will be immediately handled
// here. The corresponding timer will be invalidated in the timer's closure in
// startTimer, above.
self.scrollRectEvent = nil
scrollDelegate?.scrollViewFilter(self, adjustViewForScrollRectEvent: scrollRectEvent)
}
}
/// The amount of time remaining on the previous timer, or zero if there is no active timer.
private var remainingTimerTimeInterval: TimeInterval {
guard timer != nil,
let timerTimeInterval = timerTimeInterval,
let timerStartDate = timerStartDate else {
return 0
}
return max(0, timerTimeInterval - Date().timeIntervalSince(timerStartDate))
}
}