Files
ScrollingContentViewController/Source/ScrollingContentScrollView.swift
T
Drew Olbrich 65c738f3e4 Fix typo
2019-02-09 18:31:50 -08:00

183 lines
9.1 KiB
Swift

//
// ScrollingContentScrollView.swift
// ScrollingContentViewController
//
// Created by Drew Olbrich on 1/13/19.
// Copyright 2019 Oath Inc.
//
// Licensed under the terms of the MIT License. See the file LICENSE for the full terms.
//
import UIKit
/// A scroll view that works with `ScrollingContentViewController` and
/// `ScrollingContentViewManager`.
///
/// See [https://github.com/drewolbrich/ScrollingContentViewController](https://github.com/drewolbrich/ScrollingContentViewController/blob/master/README.md) for full documentation.
public class ScrollingContentScrollView: UIScrollView {
// The implementation of `scrollRectToVisible` provided by
// `ScrollingContentScrollView` takes part in the temporal filtering of keyboard
// frame resizing events provided by `ScrollViewFilter`. Critically, this allows
// the execution of `scrollRectToVisible` to be delayed until the scroll view's
// content size and layout has been updated to reflect the size of the keyboard.
// Without this delay, the scroll view may not scroll by the correct amount, or may
// scroll beyond the valid range of the scroll view's content offset.
/// The margin applied when UIKit automatically scrolls the scroll view to make the
/// first responder visible in response to keyboard presentation or device
/// orientation changes.
///
/// The default value is 0, which matches the UIKit behavior.
///
/// This value is also applied when `scrollFirstResponderTextFieldToVisible`,
/// `scrollViewToVisible`, or `scrollRectToVisible` are called, unless overridden
/// with the optional `margin` parameter provided by those methods.
public var visibilityScrollMargin: CGFloat = 0
private weak var scrollViewFilter: ScrollViewFilter?
internal convenience init(scrollViewFilter: ScrollViewFilter) {
self.init()
self.scrollViewFilter = scrollViewFilter
scrollViewFilter.scrollDelegate = self
// The UIScrollView contentInsetAdjustmentBehavior property must be set to always.
// If it's left at its default value, automatic, then in the case when a scrolling
// content view controller is presented outside of the context of a navigation
// controller, changes to the size of the content view will result in the content
// view's safe area insets changing unpredictably. The always behavior is chosen
// here instead of never because unlike never, the always behavior adjusts the
// scroll indicator insets, which is desirable, in particular on iPhone Xs in
// landscape orientation with the keyboard presented.
contentInsetAdjustmentBehavior = .always
}
public override func scrollRectToVisible(_ rect: CGRect, animated: Bool) {
scrollRectToVisible(rect, animated: animated, margin: nil)
}
/// Scrolls an area of the content view so it becomes visible.
///
/// Unlike the default `UIScrollView` implementation of `scrollRectToVisible`, the
/// scrolling does not take place immediately, but is submitted to
/// `ScrollViewFilter` for later processing.
///
/// Because `super.scrollRectToVisible` is not called immediately, it is possible
/// that the size and layout of the scroll view's content may have changed by the
/// time the `adjustViewForScrollRectEvent` method is called, below. Consequently,
/// this implementation of `scrollRectToVisible` makes an attempt to determine which
/// of the scroll view's descendants corresponds to the specified rectangle, if any.
/// When `adjustViewForScrollRectEvent` is called, the final rectangle is determined
/// relative to the bounds of that view. Without this approach, the scroll view may
/// scroll too far, and possibly beyond the valid content offset range of the scroll
/// view.
///
/// - Parameters:
/// - rect: The rectangular area to make visible.
/// - animated: `true` if the scrolling should be animated.
/// - margin: An optional margin around `rect` that should also be made visible.
/// If `nil`, the value of `visibilityScrollMargin` is used.
public func scrollRectToVisible(_ rect: CGRect, animated: Bool, margin: CGFloat? = nil) {
if let descendantView = self.descendantView(of: self, containing: rect, in: self) {
// If the rect matches the bounds of the descendant view, we'll substitute it with
// nil, which will be replaced with the bounds of the descendant view when it is
// processed later. The handles the case where the descendant view is resized
// between the time when self.scrollRectToVisible and super.scrollRectToVisible are
// called.
// Note: This does not handle the case where the rect is smaller than the
// descendant view's bounds and the size of the descedant view changes.
let boundsRect = descendantView.convert(rect, from: self)
let rect: CGRect? = boundsRect == descendantView.bounds ? nil : boundsRect
scrollViewFilter?.submitScrollRectEvent(ScrollRectEvent(contentArea: .descendantViewRect(rect, descendantView: descendantView), animated: animated, margin: margin ?? visibilityScrollMargin))
/// Continues in scrollViewFilter(_:adjustViewForScrollRectEvent:)...
return
}
// No appropriate descendant view could be found, so `rect` is assumed to be defined
// in the space of the scroll view's content area.
// Note: This does not handle the case where the size of the scroll view content
// area changes.
scrollViewFilter?.submitScrollRectEvent(ScrollRectEvent(contentArea: .scrollViewRect(rect), animated: animated, margin: margin ?? visibilityScrollMargin))
/// Continues in scrollViewFilter(_:adjustViewForScrollRectEvent:)...
}
/// Scrolls the scroll view to make the specified view visible.
///
/// - Parameters:
/// - view: The view to make visible.
/// - animated: If `true`, the scrolling is animated.
/// - margin: An optional margin to apply to the view. If left unspecified,
/// `scrollToVisibleMargin` is used.
public func scrollViewToVisible(_ view: UIView, animated: Bool, margin: CGFloat? = nil) {
scrollViewFilter?.submitScrollRectEvent(ScrollRectEvent(contentArea: .descendantViewRect(view.bounds, descendantView: view), animated: animated, margin: margin ?? visibilityScrollMargin))
/// Continues in scrollViewFilter(_:adjustViewForScrollRectEvent:)...
}
/// Scrolls the scroll view to make the first responder visible. If no first
/// responder is defined, this method has no effect.
///
/// - Parameters:
/// - animated: If `true`, the scrolling is animated.
/// - margin: An optional margin to apply to the first responder. If left
/// unspecified, `scrollToVisibleMargin` is used.
public func scrollFirstResponderToVisible(animated: Bool, margin: CGFloat? = nil) {
guard let view = UIResponder.rf_current as? UIView else {
return
}
scrollViewToVisible(view, animated: animated, margin: margin)
}
/// Returns the descedant view with the greatest depth whose bounds contains the
/// specified rectangle.
///
/// - Parameters:
/// - view: The view from which the search should start.
/// - rect: The rectangle to search for, defined in the coordinate space of `rectView`.
/// - rectView: The view that defines the coordinate space in which `rect` is defined.
/// - Returns: The descendant view that contains `rect`.
private func descendantView(of view: UIView, containing rect: CGRect, in rectView: UIView) -> UIView? {
let frame = rectView.convert(rect, to: view)
for subview in view.subviews {
// Perform a depth first search so the descendant view with the greatest depth that
// contains the rectangle will be found first.
if let descendantView = descendantView(of: subview, containing: rect, in: rectView) {
return descendantView
}
if subview.frame.contains(frame) {
return subview
}
}
return nil
}
}
extension ScrollingContentScrollView: ScrollViewFilterScrollDelegate {
internal func scrollViewFilter(_ scrollViewFilter: ScrollViewFilter, adjustViewForScrollRectEvent scrollRectEvent: ScrollRectEvent) {
var scrollViewRect: CGRect = .zero
switch scrollRectEvent.contentArea {
case .scrollViewRect(let rect):
scrollViewRect = rect
case .descendantViewRect(let rect, let descendantView):
// If rect is nil, make the entire descendant view visible.
// This handles the case where the descendant view has changed
// size since scrollRectToVisible was called.
let rect = rect ?? descendantView.bounds
scrollViewRect = convert(rect, from: descendantView)
}
scrollViewRect = scrollViewRect.insetBy(dx: 0, dy: -scrollRectEvent.margin)
super.scrollRectToVisible(scrollViewRect, animated: scrollRectEvent.animated)
}
}