18

What I am trying to do:

On Apple's Photo's App, if you rotate device while scroll to an arbitrary offset, same cell that was in the center beforehand would end up in the center after the rotation.

I am trying to achieve the same behavior with UICollectionView. 'indexPathsForVisibleItems' seem to be the way to do it....

What I got so far:

Following code provides smooth operation between rotates, yet the center item calculation seem to be off:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)

    self.collectionView?.collectionViewLayout.invalidateLayout()

    let middleItem = (self.collectionView?.indexPathsForVisibleItems.count / 2) - 1
    let indexPath = self.collectionView?.indexPathsForVisibleItems[middleItem]

    coordinator.animate(alongsideTransition: { ctx in

        self.collectionView?.layoutIfNeeded()
        self.collectionView?.scrollToItem(at: indexPath!, at: .centeredVertically, animated: false)

    }, completion: { _ in


    })
}

What Doesn't work with above code:

  1. Portrait -> Landscape: Somewhat off from what cell should end up in the center.
  2. Portrait -> Landscape -> Portrait: Completely off from what offset was in Portrait in the first place.
  3. Portrait -> Landscape -> Portrait -> Landscape: Way off!

Notes:

  • I have to invalidate the collection view layout upon rotation due to cell sizes and spacing having to be recalculated.
  • self.collectionView?.layoutIfNeeded() being inside the animation
    block, make the transition smooth
  • May be scrollToItem is being called before the collection view layout is not finalized, leading to incorrect scroll offset?

Alternate Method?

Instead of using 'indexPathsForVisibleItems', use just 'contentOffset'? Calculate the offset after-rotation offset? But how would this be possible when after-rotation contentSize could be different than what's expected do to different cell sizes, cell spacing etc?

Gizmodo
  • 3,151
  • 7
  • 45
  • 92
  • 1
    I've always run into trouble when trying to do a custom animation with `scrollToItem`. Maybe try putting that line in the completion block to see if you're actually targeting the right cell and position. If that works I think you'll have to do some clever calculations to animate the `contentOffset` instead of using `scrollToItem`. – Avario Jan 14 '17 at 00:41
  • If I put scrollToItem after completion, it would create a secondary scrolling animation AFTER the device has been rotated. Looks off and just doesn't feel right when it's not just one simple animation.... – Gizmodo Jan 14 '17 at 01:13
  • 1
    I was just suggesting it as an intermediate step for debugging - to check whether the issue is how you are calculating the center cell and how `scrollToItem` will position it, or whether it is a problem with the animation. – Avario Jan 14 '17 at 01:20
  • I think scrollToItem happens while new layout is not finished. – Gizmodo Jan 14 '17 at 01:22
  • Perhaps you should invalidate the layout after calculating the middle cell instead of before? – Avario Jan 14 '17 at 01:23
  • I just logged the indexPathsForVisibleItems array. It's not sorted!! It's random seems....looks like if I sort it, and THEN pick the middle item, I should have better luck... – Gizmodo Jan 14 '17 at 01:46
  • 1
    @Gizmodo I am having the same issue. It would be great if you can share some code that worked for you. – iUser Dec 12 '17 at 15:23
  • answer below helped me immensely. collectionView(_:targetContentOffsetForProposedContentOffset:) is your key. – Gizmodo Dec 12 '17 at 15:24

4 Answers4

40

You can do this by implementing the UICollectionViewDelegate method collectionView(_:targetContentOffsetForProposedContentOffset:):

During layout updates, or when transitioning between layouts, the collection view calls this method to give you the opportunity to change the proposed content offset to use at the end of the animation. You might return a new value if the layout or animations might cause items to be positioned in a way that is not optimal for your design.

Alternatively, if you've already subclassed UICollectionViewLayout, you can implement targetContentOffset(forProposedContentOffset:) in your layout subclass, which may be more convenient.

In your implementation of that method, compute and return the content offset that would cause the center cell to be positioned in the center of the collection view. If your cell sizes are fixed, it should be a simple matter of undoing the change to the content offset caused by other UI elements (such as the disappearing frames of the status bar and/or navigation bar).

If your cell sizes vary with device orientation:

  1. Prior to device rotation, fetch and save the index path of the center cell by calling indexPathForItem(at:) on your collection view, passing the content offset (plus half of the height of the collection view's visible bounds) as the point.
  2. Implement one of the targetContentOffset(forProposedContentOffset:) methods. Retrieve the layout attributes for the center cell by calling layoutAttributesForItem(at:) with the saved index path. The target content offset is the middle of the frame for that item (less half of the height of the collection view's visible bounds).

The first step could be implemented in your view controller in viewWillTransition(to:with:) or in scrollViewDidScroll(). It could also be implemented in your layout object in prepareForAnimatedBoundsChange(), or perhaps in invalidateLayout(with:) after checking for a bounds change caused by a change in device orientation. (You would also need to ensure that shouldInvalidateLayout(forBoundsChange:) returns true in those circumstances.)

You may need to make adjustments for content insets, cell spacing, or other matters specific to your app.

jamesk
  • 3,807
  • 21
  • 38
  • 2
    Great answer with excellent explanation! It works flawlessly. Did have few troubles converting CGPoints from view to view, but managed to get everything sorted out. Great job! – Gizmodo Jan 18 '17 at 22:22
  • 4
    Sadly `targetContentOffsetForProposedContentOffset` is not called on iPad when split view changes. It means rotations and width changes cannot be universally handled. – an0 Nov 21 '17 at 21:10
  • "The target content offset is the middle of the frame for that item (less half of the height of the collection view's visible bounds)." -- I used `attr.frame.origin` instead in `targetContentOffset(forProposedContentOffset:)` for a horizontally layouted & paginated collection view. – LShi Mar 19 '18 at 09:31
16

This is the implementation of jamesk solution:

In your ViewController:

fileprivate var prevIndexPathAtCenter: IndexPath?

fileprivate var currentIndexPath: IndexPath? {
    let center = view.convert(collectionView.center, to: collectionView)
    return collectionView.indexPathForItem(at: center)
}

override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
    super.willTransition(to: newCollection, with: coordinator)

    if let indexAtCenter = currentIndexPath {
        prevIndexPathAtCenter = indexAtCenter
    }
    collectionView.collectionViewLayout.invalidateLayout()
}

In UICollectionViewDelegateFlowLayout:

func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {

    guard let oldCenter = prevIndexPathAtCenter else {
        return proposedContentOffset
    }

    let attrs =  collectionView.layoutAttributesForItem(at: oldCenter)

    let newOriginForOldIndex = attrs?.frame.origin

    return newOriginForOldIndex ?? proposedContentOffset
}
Eric Aya
  • 69,473
  • 35
  • 181
  • 253
0rt
  • 1,325
  • 2
  • 17
  • 25
  • 6
    Tried this out and found that you want to use `viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)` rather than `willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator)` (as initially mentioned in jamesk's solution) as sometimes rotation doesn't change trait collections but will change size (eg. iPad) – Ohifriend Aug 11 '20 at 07:02
  • `collectionView.layoutAttributesForItem` was still using the old collection view's layout attributes. Instead, `let attributes = layoutAttributes[prevIndexPathAtCenter.item]` worked for me (where `layoutAttributes` is from [this code](https://stackoverflow.com/a/68119885/14351818)). – aheze Aug 14 '21 at 23:12
0

As @jamesk said we have different way to do it, one of them is this:

I have a collectionView in a ViewController with its default UICollectionViewDelegateFlowLayout. (I don't override it).

I have only one section.

In advance I want to tanks of @jamesk and @0rt for their answers.

class ViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout,  UICollectionViewDataSource {

    var currentVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0)



    // 1. get the current Index of item
    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {

        let center = CGPoint(x: scrollView.contentOffset.x + (scrollView.frame.width / 2), y: (scrollView.frame.height / 2))
        if let ip = self.screenView.indexPathForItem(at: center) {
            currentVisibleIndexPath = ip
        }
    }

    // 2. if transition happen, invalidateLayout of collectionView, to recalculate layout positions, but this does not change its offset

    override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
        super.willTransition(to: newCollection, with: coordinator)

        collectionView.collectionViewLayout.invalidateLayout()
    }

    // 3. change offset to make current index to center of collection view
    // copy of @0rt answer
    func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {

        let attrs =  collectionView.layoutAttributesForItem(at: currentVisibleIndexPath)

        let newOriginForOldIndex = attrs?.frame.origin

        return newOriginForOldIndex ?? proposedContentOffset
    }
}

Esmaeil
  • 558
  • 5
  • 11
0

Accepted answer did not work for me. In my case neither method of UICollectionViewDelegate

collectionView(_:targetContentOffsetForProposedContentOffset:)

nor the method of UICollectionViewLayout was called

targetContentOffset(forProposedContentOffset:)

Instead, I inherited from UICollectionView and overrode layoutSubviews method:

class CollectionView: UICollectionView {
    override func layoutSubviews() {
        let old = contentOffset
        super.layoutSubviews()
        contentOffset = old
    }
}

This may cause some side effects, so check it carefully before use in production

Anton Belousov
  • 1,140
  • 15
  • 34