Skip to content

Instantly share code, notes, and snippets.

@felipericieri
Created August 6, 2019 09:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save felipericieri/2d10bda025c4039620a09d9e43fbd8d1 to your computer and use it in GitHub Desktop.
Save felipericieri/2d10bda025c4039620a09d9e43fbd8d1 to your computer and use it in GitHub Desktop.
A `UIViewController` with a `UIScrollView` embeded that acts like a View Container
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