Where can I set contentOffset to the last item in UICollectionView before the view appears?

Viewed 5060

I am scrolling multiple horizontal collectionViews to the last (Far right) item using:

 timelineCollectionView.scrollToMaxContentOffset(animated: false)
   postsCollectionView.scrollToMaxContentOffset(animated: false)

this works great, except I can't figure out where to put them.

Various places I've tried:

viewWillAppear - it doesn't scroll as though the cells aren't fully loaded yet.

viewDidAppear - it does scroll perfectly. Awesome! Except now you can see it scroll even when you put animated: false. The collectionView loads at the far left for a split second before updating to the far right

viewDidLayoutSubviews - this works perfectly - timing and everything! However, it gets called many times. If I could determine which subview was just laid out, perhaps I could scroll it in there but Im not sure how.

Is there a better option? How can I do this?

Also these are the functions I am using to set content offset:

extension UIScrollView {

    var minContentOffset: CGPoint {
        return CGPoint(
            x: -contentInset.left,
            y: -contentInset.top)
    }

    var maxContentOffset: CGPoint {
        return CGPoint(
            x: contentSize.width - bounds.width + contentInset.right,
            y: contentSize.height - bounds.height + contentInset.bottom)
    }

    func scrollToMinContentOffset(animated: Bool) {
        setContentOffset(minContentOffset, animated: animated)
    }

    func scrollToMaxContentOffset(animated: Bool) {
        setContentOffset(maxContentOffset, animated: animated)
    }
}
4 Answers

Is there a better option?

Yes there is, and that would be UICollectionView.scrollToItem(at:at:animated:)

Next is to wait until cells are dequeued so we can call UICollectionView.scrollToItem(at:at:animated:), and there are multiple ways you can do this.

You can call UICollectionView.reloadData within animateWithDuration:animations:completion it works both in viewDidLoad or viewWillAppear, scrolling is seemless too, like so:

func scrollToItem(at index: Int) {
    UIView.animate(withDuration: 0.0, animations: { [weak self] in
        self?.collectionView.reloadData()
    }, completion: { [weak self] (finished) in
        if let count = self?.dataSource?.count, index < count {
            let indexPath = IndexPath(item: index, section: 0)
            self!.collectionView.scrollToItem(at: indexPath, at: UICollectionView.ScrollPosition.right, animated: false)
        }
    })
}

Another way is to dispatch a code block to main queue after UICollectionView.reloadData, like so:

func scrollToItem(at index: Int) {
    self.collectionView.reloadData()
    DispatchQueue.main.async { [weak self] in
        // this will be executed after cells dequeuing
        if let count = self?.dataSource?.count, index < count {
            let indexPath = IndexPath(item: index, section: 0)
            self!.collectionView.scrollToItem(at: indexPath, at: UICollectionView.ScrollPosition.right, animated: false)
        }
    }
}

I have also created a gist where I am using a horizontal UICollectionView, just create a new xcode project and replace ViewController.swift with the gist source code.

It seems the best place to do it is in viewWillAppear and its now working. Note that I had to add some calls:

override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        let postCount = posts.fetchedObjects?.count
        let lastPostIndex = IndexPath(item: max(postCount! - 1,0), section: 0)
        postsCollectionView.setNeedsLayout()
        postsCollectionView.layoutIfNeeded()
        postsCollectionView.scrollToItem(at: lastPostIndex, at: .centeredHorizontally, animated: false) // you can also do setContentOffset here
}

The reason that my viewWillAppear wasn't working before is that it didn't know the contentSize of my collectionView and it thought the contentSize was 0 so scrollToItem and setContentOffset didn't move it anywhere. layoutIfNeeded() checks the contentOffset and now that its non-zero, it moves it the appropriate amount.

Edit: In fact you can even put this in ViewDidLoad if you want so that it doesn't re-fire when you add and dismiss a supplementary view for instance. The key really is that layoutIfNeeded command which accesses the contentSize.

you can use performBatchUpdate in viewdidLoad.

timelineCollectionView.reloadData()
timelineCollectionView.performBatchUpdates(nil, completion: {
    (result) in
     timelineCollectionView.scrollToMaxContentOffset(animated: false)
})

Second option is you can put observer for collection view ContentSize.

override func viewDidLoad() {
    super.viewDidLoad()
    timelineCollectionView.addObserver(self, forKeyPath: "contentSize", options: NSKeyValueObservingOptions.old, context: nil)
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    timelineCollectionView.removeObserver(self, forKeyPath: "contentSize")
}

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if let observedObject = object as? UICollectionView, observedObject == timelineCollectionView {
        print("set ContentOffset here")
        timelineCollectionView.scrollToMaxContentOffset(animated: false)
    }
}

As you need the content size and the bounds of your scrollView to compute your target content offset, you need your scrollView to be laid out before setting it.

viewDidLayoutSubviews looks good to me too. It is called each time the view of your view controller has just laid out its subviews (only its subviews, not the subviews of its subviews). Just add a flag to make sure the change is done only once. The hack is pretty common when you handle rotations or trait collection changes.

var firstTime = true

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    guard firstTime else { return }
    firstTime = false
    collectionView.scrollToMaxContentOffset(animated: false)
}

Is there a better option?

  • viewWillAppear is called too early. The bounds and the content size are wrong at this time.

  • viewDidAppear is called too late. Too bad, the layout is done though. It is called once the animation of the view controller presentation is done, so the content offset of your scroll view will be at zero during all the animation duration.

  • Hacking the completion blocks of UIView.animation or performBatchUpdates ? Well, it works but this is totally counterintuitive. I think this is not a proper solution.

Besides, as specified in the documentation :

completion: [...] If the duration of the animation is 0, this block is performed at the beginning of the next run loop cycle.

You are just adding an extra useless cycle. viewDidLayoutSubviews is called just before the end of the current one.

Related