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
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 issueHope this explanation helps 🙂
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:
As you scroll your
collectionView
fromCustomViewController
up/down, you are using the updated scroll offset throughViewControllerChildDelegate
to adjustpagerViewTopAnchorConstraint
.Adjusting this constraint also adjusts the height of your
pageViewController
which in turn adjusts yourCustomViewController
‘scollectionView
heightAdjusting the
collectionView
‘s height can cause jumps in itscontentOffset
depending on its current offset. Here’s an answer which explains this: https://stackoverflow.com/a/21787754/9293498Just 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’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’scontentOffset
as such: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:
collectionView
‘scontentOffset
gets updated based on the value fromcollectionView.contentSize.height - collectionView.frame.height
which I’m guessing is attributed to the frame changecollectionView
‘scontentSize
also gets a massive drop/rise in height (I’m talking 1000s) which is far from yourpager
‘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.