Created
August 6, 2019 09:52
-
-
Save felipericieri/2d10bda025c4039620a09d9e43fbd8d1 to your computer and use it in GitHub Desktop.
A `UIViewController` with a `UIScrollView` embeded that acts like a View Container
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
/// Objects conforming with `ScrollingAdditionalViewController` need to share its preferred height in stack | |
public protocol ScrollingAdditionalViewController where Self: UIViewController { | |
var preferredHeightInStack: CGFloat { get } | |
} | |
/// `NestedScrollViewController` represents the nested `UIScrollView` in `ScrollingController` | |
public protocol NestedScrollViewController where Self: UIViewController { | |
var view: UIView! { get } | |
var scrollView: UIScrollView { get } | |
} | |
/// A `UIViewController` with a `UIScrollView` embeded that acts like a View Container | |
open class ScrollingController: UIViewController, UIScrollViewDelegate { | |
// MARK: - Properties | |
/// The root view controller is a `UIViewController` with a `UIScrollView` property | |
public unowned let rootViewController: NestedScrollViewController | |
/// The Additional View Controllers are any `UIViewController` that conform with `ScrollingAdditionalViewController`. Additional View Controllers appears on the top of the nested View Controller | |
public var additionalViewControllers: [ScrollingAdditionalViewController] = [] { | |
didSet { | |
resetAdditionalViewControllers() | |
additionalViewControllers.forEach { addChild($0) } | |
layoutStack() | |
} | |
} | |
/// Main `UIScrollView` from this container | |
public lazy var scrollView: UIScrollView = { | |
let scrollView = UIScrollView() | |
scrollView.alwaysBounceVertical = true | |
scrollView.contentInsetAdjustmentBehavior = .never | |
scrollView.showsVerticalScrollIndicator = false | |
scrollView.delegate = self | |
view.addSubview(scrollView) | |
scrollView.translatesAutoresizingMaskIntoConstraints = false | |
let constraints: [NSLayoutConstraint] = [ | |
.init(item: scrollView, attribute: .top, relatedBy: .equal, toItem: view, attribute: .top, multiplier: 1, constant: 0), | |
.init(item: scrollView, attribute: .bottom, relatedBy: .equal, toItem: view, attribute: .bottom, multiplier: 1, constant: 0), | |
.init(item: scrollView, attribute: .left, relatedBy: .equal, toItem: view, attribute: .left, multiplier: 1, constant: 0), | |
.init(item: scrollView, attribute: .right, relatedBy: .equal, toItem: view, attribute: .right, multiplier: 1, constant: 0) | |
] | |
NSLayoutConstraint.activate(constraints) | |
return scrollView | |
}() | |
private var nestedScrollViewMinY: CGFloat = 0 | |
// MARK: - Initialisers | |
public required init(rootViewController: NestedScrollViewController) { | |
self.rootViewController = rootViewController | |
super.init(nibName: nil, bundle: nil) | |
addChild(rootViewController) | |
} | |
public required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) has not been implemented, use init(rootViewController:) instead.") | |
} | |
// MARK: - Lifecycle | |
open override func viewDidLoad() { | |
super.viewDidLoad() | |
scrollView.delegate = self | |
} | |
open override func viewWillAppear(_ animated: Bool) { | |
super.viewWillAppear(animated) | |
addObservers() | |
rootViewController.scrollView.isScrollEnabled = false | |
childrenWillMove(to: self) | |
} | |
open override func viewDidAppear(_ animated: Bool) { | |
super.viewDidAppear(animated) | |
childrenDidMove(to: self) | |
} | |
open override func viewWillDisappear(_ animated: Bool) { | |
super.viewWillDisappear(animated) | |
removeObservers() | |
childrenWillMove(to: nil) | |
childrenDidMove(to: nil) | |
} | |
open override func viewDidLayoutSubviews() { | |
layoutStack() | |
} | |
// MARK: - Layout adjustments | |
private func layoutStack() { | |
let bounds = scrollView.bounds | |
var offsetY: CGFloat = 0.0 | |
var contentSizeHeight: CGFloat = 0 | |
// Additional View Controllers positioning | |
for viewController in additionalViewControllers { | |
let itemHeight = viewController.preferredHeightInStack | |
let rect = CGRect(x: 0.0, y: offsetY, width: bounds.width, height: itemHeight) | |
viewController.view.frame = rect | |
scrollView.addSubview(viewController.view) | |
contentSizeHeight += itemHeight | |
offsetY += itemHeight | |
} | |
nestedScrollViewMinY = offsetY | |
// Setting main scroll view position | |
let rect = CGRect(x: 0.0, y: offsetY, width: bounds.width, height: bounds.height) | |
rootViewController.view.frame = rect | |
scrollView.addSubview(rootViewController.view) | |
contentSizeHeight += rootViewController.scrollView.contentSize.height | |
scrollView.contentSize = CGSize(width: bounds.width, height: contentSizeHeight) | |
adjustContentOffsetOnScroll() | |
} | |
private func nestedScrollViewContentSizeDidUpdate() { | |
let lastOffsetY = scrollView.contentOffset.y | |
scrollView.contentSize.height = rootViewController.scrollView.contentSize.height + nestedScrollViewMinY | |
adjustContentOffsetOnScroll(lastOffsetY) | |
} | |
private func adjustContentOffsetOnScroll(_ offsetY: CGFloat? = nil) { | |
var contentOffsetY: CGFloat | |
// Here we have a chance to set the scroll to a specific point | |
if let offsetY = offsetY { | |
contentOffsetY = offsetY | |
} else { | |
// Otherwise use the current main scroll content offset | |
contentOffsetY = scrollView.contentOffset.y | |
} | |
// the start position of the main scroll in stack (it can vary depending on how many additional view controllers were added to stack) | |
let minY: CGFloat = nestedScrollViewMinY | |
// If the current content offset is higher than the starting Y point, | |
if contentOffsetY > minY { | |
// The root view controller frame is moved along | |
rootViewController.view.frame.origin.y = contentOffsetY | |
// ... and the nested scroll view content offset is updated to this position MINUS the starting point | |
rootViewController.scrollView.contentOffset = CGPoint(x: 0, y: contentOffsetY - minY) | |
// Otherwise... | |
} else { | |
// the root view controller stays the base point | |
rootViewController.view.frame.origin.y = minY | |
// ... and the nested scroll view content offset as well | |
rootViewController.scrollView.contentOffset = .zero | |
} | |
} | |
// MARK: - Child View Controllers Handling | |
private func resetAdditionalViewControllers() { | |
additionalViewControllers.forEach { | |
$0.willMove(toParent: nil) | |
$0.removeFromParent() | |
$0.view.removeFromSuperview() | |
$0.didMove(toParent: nil) | |
} | |
} | |
private func childrenWillMove(to parent: UIViewController? = nil) { | |
additionalViewControllers.forEach { $0.willMove(toParent: parent) } | |
rootViewController.willMove(toParent: parent) | |
} | |
private func childrenDidMove(to parent: UIViewController? = nil) { | |
additionalViewControllers.forEach { $0.didMove(toParent: parent) } | |
rootViewController.didMove(toParent: parent) | |
} | |
// MARK: - UIScrollViewDelegate | |
open func scrollViewDidScroll(_ scrollView: UIScrollView) { | |
guard scrollView == self.scrollView else { return } | |
print("scroll offset \(scrollView.contentOffset.y), scroll height \(scrollView.contentSize.height), nested offset \(rootViewController.scrollView.contentOffset.y), nested c height \(rootViewController.scrollView.contentSize.height)") | |
adjustContentOffsetOnScroll() | |
} | |
// MARK: - KVO Observers | |
private func addObservers() { | |
rootViewController.scrollView.addObserver(self, forKeyPath: "contentSize", options: [.old, .new], context: nil) | |
} | |
private func removeObservers() { | |
rootViewController.scrollView.removeObserver(self, forKeyPath: "contentSize", context: nil) | |
} | |
// swiftlint:disable block_based_kvo | |
open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { | |
nestedScrollViewContentSizeDidUpdate() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment