Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Transfer interaction of UIScrollView to another UIScrollView mid-scroll

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: Layout

My question is:

  • If the user scrolls to the bottom of ScrollViewA so that ProfileDetailViewController and ScrollViewB is visible.
  • The user scrolls down a bit on ScrollViewB then releases.
  • Then the user scrolls up on ScrollViewB.
  • Whilst still holding down the finger, when the user reaches the top of the content in 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

like image 436
jacobsieradzki Avatar asked Dec 19 '18 05:12

jacobsieradzki


2 Answers

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)
like image 20
pxpgraphics Avatar answered Nov 17 '22 10:11

pxpgraphics


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 ;)

like image 176
Bioche Avatar answered Nov 17 '22 08:11

Bioche