skip to Main Content

I have a UIViewController which includes a UIPageViewController which itself includes a UICollectionViewController.

Above the UIPageViewController: a UIView (named pagerView) which moves up or down depending on the vertical scrolling-offset of the UICollectionViewController.

Finally, the UIPageViewController’s top anchor is constrained to pagerView‘s bottom anchor.

The problem is that scrolling up is not continuous, the UICollectionViewController sometimes "jumps" (it can be a few hundreds of points.)

In order to reproduce: scroll to the bottom (row 9) then start to scroll up and see how it jumps to row 7.

The source of the UIViewController:

import UIKit

protocol ViewControllerChildDelegate: AnyObject {
    func childScrollViewWillBeginDragging(with offset: CGFloat)
    func childScrollViewDidScroll(to offset: CGFloat)
}

class ViewController: UIViewController {
    private lazy var pageViewController: UIPageViewController = {
        let viewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
        viewController.delegate = self
        viewController.dataSource = self
        return viewController
    }()
    
    private lazy var pagerView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .brown
        return view
    }()
    
    private let pagerViewHeight: CGFloat = 44
    private var lastContentOffset: CGFloat = 0
    
    lazy var pagerViewTopAnchorConstraint: NSLayoutConstraint = {
        return pagerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "xxx"

        view.addSubview(pagerView)
        
        addChild(pageViewController)
        view.addSubview(pageViewController.view)
        pageViewController.didMove(toParent: self)
        pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            pagerViewTopAnchorConstraint,
            pagerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pagerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            pagerView.heightAnchor.constraint(equalToConstant: pagerViewHeight),
            
            pageViewController.view.topAnchor.constraint(equalTo: pagerView.bottomAnchor),
            pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            pageViewController.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
        ])
        
        let viewController = CustomViewController()
        viewController.delegate = self
        
        pageViewController.setViewControllers(
            [viewController],
            direction: .forward,
            animated: false,
            completion: nil
        )
    }
}

extension ViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        return nil
    }

    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        return nil
    }
    
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
    }
}

extension ViewController: ViewControllerChildDelegate {
    func childScrollViewWillBeginDragging(with offset: CGFloat) {
        lastContentOffset = offset
    }
    
    func childScrollViewDidScroll(to offset: CGFloat) {
        if lastContentOffset > offset {
            view.layoutIfNeeded()
            pagerViewTopAnchorConstraint.constant = 0
            UIView.animate(withDuration: 0.3, animations: { [weak self] in
                self?.view.layoutIfNeeded()
            })
        } else if lastContentOffset < offset {
            view.layoutIfNeeded()
            pagerViewTopAnchorConstraint.constant = -pagerViewHeight
            UIView.animate(withDuration: 0.3, animations: { [weak self] in
                self?.view.layoutIfNeeded()
            })
        }
    }
}

The whole code can be viewed in this gist. There is also a XCode project to download.

And also: a video demonstrating the problem.

2

Answers


  1. Looking at the video provided, the issue seems to be due to the implementation of reusable cells.

    When you first launch the app, there would be n cells (4 in your case) which are created as n instances. At this point, the n instances are in memory. when you scroll down, you don’t see any issue cause the number of cells on screen reduces but still you are not creating any cells on the spot. On the other hand, when scrolling upward, you have n=2 cells and you suddenly need more when scrolling which creates the missing cells at the spot and causes the lagging jumps.

    In a typical tableview, the number of visible cells is constant, such that you will always cache the same number in memory and reuse it when it goes beyond the screen.

    To sum it up:
    going from n to n -> no issue, you cache probably n+1 so you always have cells to reuse
    going from n to k (k < n) -> no issue, you’ll reduce the cached cell as you scroll
    going from n to k (k > n) -> causes lagging, you’ll have to create the reusable cells on the spot or reuse whatever already shown on screen while you can

    You can fix this by simply replacing collectionView.dequeueReusableCell with you own array where you store the initiated cells and reuse them. This of course will require special handling but I believe it would fix your issue

    Hope this explanation helps 🙂

    Login or Signup to reply.
  2. After spending some time with your project, I haven’t found a fix yet but I believe I’ve found things which can point you towards a fix.

    Here are my observations:

    1. As you scroll your collectionView from CustomViewController up/down, you are using the updated scroll offset through ViewControllerChildDelegate to adjust pagerViewTopAnchorConstraint.

    2. Adjusting this constraint also adjusts the height of your pageViewController which in turn adjusts your CustomViewController‘s collectionView height

    3. Adjusting the collectionView‘s height can cause jumps in its contentOffset depending on its current offset. Here’s an answer which explains this: https://stackoverflow.com/a/21787754/9293498

      Just to fact-check this, you can comment the parts where you update your pagerViewTopAnchorConstraint, and you’ll notice that doing so will get rid of the ‘jumps’

    4. To further verify if this was indeed the case, I created two KVO observers: One for the collectionView’s contentSize and the other for collectionView’s contentOffset as such:

       contentSizeObserver = collectionView.observe(.contentSize) { [weak self] collectionView, _ in
           //Ignore the values on "Found you!" checks, as its relative to my device
           if collectionView.contentSize.height < 3000 {
               print("Found you!")
           }
           print("CONTENT SIZE SET: ", collectionView.contentSize)
       }
      
       contentOffsetObserver = collectionView.observe(.contentOffset) { [weak self] collectionView, _ in
           //Ignore the values on "Found you!" checks, as its relative to my device
           if collectionView.contentOffset.y < 1460 {
               print("Found you!")
           }
           print("CONTENTOFFSET SET: ", collectionView.contentOffset)
       }
      

      where I noticed I could catch the ‘jumps’ with breakpoints based on the values in my "Found you!" checks. Basically, when you reach Row 9 and try to scroll up, there are two things happening whenever a ‘jump’ is about to happen:

      • Your collectionView‘s contentOffset gets updated based on the value from collectionView.contentSize.height - collectionView.frame.height which I’m guessing is attributed to the frame change
      • For some reason, your collectionView‘s contentSize also gets a massive drop/rise in height (I’m talking 1000s) which is far from your pager‘s height (which is 44)

      These couple changes added together is what we notice as a ‘jump’ which sorts itself out after some recursive calls during frame change.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search