I have an interface with the following structure, with names for important elements in brackets:
- UIViewController
- UIScrollView (ScrollViewA)
- UIViewController (ProfileOverviewViewController)
- UIViewController (ProfileDetailViewController)
- UICollectionView (ScrollViewB)
So essentially there is a vertical scroll view (ScrollViewB
) inside another vertical scroll view (ScrollViewA
). ProfileOverviewViewController
and ProfileDetailViewController
are both the size of the device screen, so ScrollViewB
is only visible once ScrollViewA
is scrolled to the bottom.
Pagination is enabled on ScrollViewA
, so snaps to either ProfileOverviewViewController
taking the whole screen view or ProfileDetailViewController
taking the whole screen view.
Hopefully this graphic makes the layout a bit more clear:
My question is:
ScrollViewA
so that ProfileDetailViewController
and ScrollViewB
is visible.ScrollViewB
then releases.ScrollViewB
.ScrollViewB
, ScrollViewB
should stop scrolling and the ScrollViewA
should start scrolling up towards ProfileOverviewViewController
, all within the same finger gesture from the user.Instead of at the moment where ScrollViewB
will simply extend to a negative y content offset since bounces
property is true.
How can I transfer the scrolling of ScrollViewB to ScrollViewA when it reaches the top?
Thanks in advance
Below is an example with a slightly modified version of the structure listed by OP. I've updated the structure here:
- UIPageViewController
- UIViewController (ProfileOverviewViewController)
- UIViewController (ProfileDetailViewController)
- UICollectionView (ScrollViewB)
We've replaced the parent view controller, which contained a scroll view, with an instance of a UIPageViewController. The purpose for this change was to gain the pagination functionality, and the UIPageViewControllerDataSource & UIPageViewControllerDelegate functions as well.
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
// MARK: - ScrollViewAController
final class ScrollViewAController : UIPageViewController {
private var _viewControllers: [UIViewController] = []
convenience init(viewControllers: [UIViewController]) {
self.init(transitionStyle: .scroll, navigationOrientation: .vertical, options: nil)
self._viewControllers = viewControllers
dataSource = self
}
override func viewDidLoad() {
super.viewDidLoad()
setViewControllers([_viewControllers.first!], direction: .forward, animated: true, completion: nil)
}
}
// MARK: UIPageViewControllerDataSource
extension ScrollViewAController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
let viewControllers = _viewControllers
guard let index = _viewControllers.index(of: viewController) else {
return nil // view controller not found
}
let previousIndex = index - 1
guard previousIndex >= 0 else {
return nil // index is invalid
}
guard viewControllers.count > previousIndex else {
return nil // previous index is invalid
}
return viewControllers[previousIndex]
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
let viewControllers = _viewControllers
guard let index = viewControllers.index(of: viewController) else {
return nil // view controller not found
}
let nextIndex = index + 1
let viewControllersCount = viewControllers.count
guard viewControllersCount != nextIndex else {
return nil // next index is out-of-bounds (we're at the last page)
}
guard viewControllersCount > nextIndex else {
return nil // next index is invalid
}
return viewControllers[nextIndex]
}
}
// MARK: - ProfileOverviewViewController
final class ProfileOverviewViewController : UIViewController {
override func loadView() {
let view = UIView()
view.backgroundColor = .green
let label = UILabel()
label.text = "ProfileOverviewViewController"
label.textAlignment = .center
label.textColor = .white
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
label.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
self.view = view
}
}
// MARK: - ProfileDetailViewController
final class ProfileDetailViewController : UIViewController {
var scrollViewB: UIScrollView! // should be a collection view, but simplified for this sample.
override func loadView() {
let view = UIView()
view.backgroundColor = .orange
let label = UILabel()
label.text = "ProfileDetailViewController"
label.textAlignment = .center
label.textColor = .white
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
label.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
label.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
label.heightAnchor.constraint(equalToConstant: 100).isActive = true
scrollViewB = UIScrollView()
scrollViewB.backgroundColor = .blue
view.addSubview(scrollViewB)
scrollViewB.translatesAutoresizingMaskIntoConstraints = false
scrollViewB.topAnchor.constraint(equalTo: label.bottomAnchor).isActive = true
scrollViewB.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 50).isActive = true
scrollViewB.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -50).isActive = true
scrollViewB.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
let scrollViewBLabel = UILabel()
scrollViewBLabel.numberOfLines = 0
scrollViewBLabel.text = "ScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\nScrollViewB\n"
scrollViewBLabel.textAlignment = .center
scrollViewBLabel.textColor = .white
scrollViewB.addSubview(scrollViewBLabel)
scrollViewBLabel.translatesAutoresizingMaskIntoConstraints = false
scrollViewBLabel.topAnchor.constraint(equalTo: scrollViewB.topAnchor).isActive = true
scrollViewBLabel.leadingAnchor.constraint(equalTo: scrollViewB.leadingAnchor).isActive = true
scrollViewBLabel.trailingAnchor.constraint(equalTo: scrollViewB.trailingAnchor).isActive = true
scrollViewBLabel.heightAnchor.constraint(equalToConstant: 1500).isActive = true
scrollViewBLabel.bottomAnchor.constraint(equalTo: scrollViewB.bottomAnchor).isActive = true
self.view = view
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollViewB.contentSize = CGSize(width: view.frame.width, height: 2000)
}
}
// Present the view controller in the Live View window
let viewControllers: [UIViewController] = [
ProfileOverviewViewController(),
ProfileDetailViewController(),
]
PlaygroundPage.current.liveView = ScrollViewAController(viewControllers: viewControllers)
That's a pretty good question & I had to dig a little to find a suitable solution. Here is the commented code. The idea is to add a custom pan gesture to scrollViewB & set the ProfileDetailViewController as its gesture delegate. When the panning brings scrollViewB to its top, the ProfileOverviewViewController gets warned & starts scrolling scrollViewA. When the user releases its finger ProfileOverviewViewController decides wether to scroll to the bottom or top of the content.
Hope it helps :)
ProfileDetailViewController :
//
// ProfileDetailViewController.swift
// Sandbox
//
// Created by Eric Blachère on 23/12/2018.
// Copyright © 2018 Eric Blachère. All rights reserved.
//
import UIKit
protocol OverflowDelegate: class {
func onOverflowEnded()
func onOverflow(delta: CGFloat)
}
/// State of overflow of scrollView
///
/// - on: The scrollview is overflowing : ScrollViewA should take the lead. We store the last trnaslation of the gesture
/// - off: No overflow detected
enum OverflowState {
case on(lastRecordedGestureTranslation: CGFloat)
case off
var isOn: Bool {
switch self {
case .on:
return true
case .off:
return false
}
}
}
class ProfileDetailViewController: UIViewController, UIScrollViewDelegate, UIGestureRecognizerDelegate {
@IBOutlet weak var scrollviewB: UIScrollView!
weak var delegate: OverflowDelegate?
/// a pan gesture added on scrollView B
var customPanGesture: UIPanGestureRecognizer!
/// The state of the overflow
var overflowState = OverflowState.off
override func viewDidLoad() {
super.viewDidLoad()
// create a custom pan gesture recognizer added on scrollview B. This way we can be delegate of this gesture & follow the finger
customPanGesture = UIPanGestureRecognizer(target: self, action: #selector(self.panRecognized(gesture:)))
scrollviewB.addGestureRecognizer(customPanGesture)
customPanGesture.delegate = self
scrollviewB.delegate = self
}
@objc func panRecognized(gesture: UIPanGestureRecognizer) {
switch overflowState {
case .on(let lastRecordedGestureTranslation):
// the user just released his finger
if gesture.state == .ended {
print("didEnd !!")
delegate?.onOverflowEnded() // warn delegate
overflowState = .off // end of overflow
scrollviewB.panGestureRecognizer.isEnabled = true // enable scroll again
return
}
// compute the translation delta & send it to delegate
let fullTranslationY = gesture.translation(in: view).y
let delta = fullTranslationY - lastRecordedGestureTranslation
overflowState = .on(lastRecordedGestureTranslation: fullTranslationY)
delegate?.onOverflow(delta: delta)
case .off:
return
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.contentOffset.y <= 0 { // scrollview B is at the top
// if the overflow is starting : initilize
if !overflowState.isOn {
let translation = self.customPanGesture.translation(in: self.view)
self.overflowState = .on(lastRecordedGestureTranslation: translation.y)
// disable scroll as we don't scroll in this scrollView from now on
scrollView.panGestureRecognizer.isEnabled = false
}
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true // so that both the pan gestures on scrollview will be triggered
}
}
GlobalViewController :
//
// GlobalViewController.swift
// Sandbox
//
// Created by Eric Blachère on 23/12/2018.
// Copyright © 2018 Eric Blachère. All rights reserved.
//
import UIKit
class GlobalViewController: UIViewController, OverflowDelegate {
@IBOutlet weak var scrollViewA: UIScrollView!
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard segue.identifier == "secondSegue", let ctrl = segue.destination as? ProfileDetailViewController else {
return
}
ctrl.delegate = self
}
func onOverflowEnded() {
// scroll to top if at least one third of the overview is showed (you can change this fraction as you please ^^)
let shouldScrollToTop = (scrollViewA.contentOffset.y <= 2 * scrollViewA.frame.height / 3)
if shouldScrollToTop {
scrollViewA.scrollRectToVisible(CGRect(x: 0, y: 0, width: 1, height: 1), animated: true)
} else {
scrollViewA.scrollRectToVisible(CGRect(x: 0, y: scrollViewA.contentSize.height - 1, width: 1, height: 1), animated: true)
}
}
func onOverflow(delta: CGFloat) {
// move the scrollview content
if scrollViewA.contentOffset.y - delta <= scrollViewA.contentSize.height - scrollViewA.frame.height {
scrollViewA.contentOffset.y -= delta
print("difference : \(delta)")
print("contentOffset : \(scrollViewA.contentOffset.y)")
}
}
}
Edit : ProfileOverviewViewController & ProfileDetailViewController are set in the GlobalViewController in a storyboard via container views but it should work as well if set in code ;)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With