Keeping the contentOffset in a UICollectionView while rotating Interface Orientation

41,138

Solution 1

You can either do this in the view controller:

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

    guard let collectionView = collectionView else { return }
    let offset = collectionView.contentOffset
    let width = collectionView.bounds.size.width

    let index = round(offset.x / width)
    let newOffset = CGPoint(x: index * size.width, y: offset.y)

    coordinator.animate(alongsideTransition: { (context) in
        collectionView.reloadData()
        collectionView.setContentOffset(newOffset, animated: false)
    }, completion: nil)
}

Or in the layout itself: https://stackoverflow.com/a/54868999/308315

Solution 2

Solution 1, "just snap"

If what you need is only to ensure that the contentOffset ends in a right position, you can create a subclass of UICollectionViewLayout and implement targetContentOffsetForProposedContentOffset: method. For example you could do something like this to calculate the page:

- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
    NSInteger page = ceil(proposedContentOffset.x / [self.collectionView frame].size.width);
    return CGPointMake(page * [self.collectionView frame].size.width, 0);
}

But the problem that you'll face is that the animation for that transition is extremely weird. What I'm doing on my case (which is almost the same as yours) is:

Solution 2, "smooth animation"

1) First I set the cell size, which can be managed by collectionView:layout:sizeForItemAtIndexPath: delegate method as follows:

- (CGSize)collectionView:(UICollectionView *)collectionView
                  layout:(UICollectionViewLayout  *)collectionViewLayout
  sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
    return [self.view bounds].size;
}

Note that [self.view bounds] will change according to the device rotation.

2) When the device is about to rotate, I'm adding an imageView on top of the collection view with all resizing masks. This view will actually hide the collectionView weirdness (because it is on top of it) and since the willRotatoToInterfaceOrientation: method is called inside an animation block it will rotate accordingly. I'm also keeping the next contentOffset according to the shown indexPath so I can fix the contentOffset once the rotation is done:

- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation
                                duration:(NSTimeInterval)duration
{
    // Gets the first (and only) visible cell.
    NSIndexPath *indexPath = [[self.collectionView indexPathsForVisibleItems] firstObject];
    KSPhotoViewCell *cell = (id)[self.collectionView cellForItemAtIndexPath:indexPath];

    // Creates a temporary imageView that will occupy the full screen and rotate.
    UIImageView *imageView = [[UIImageView alloc] initWithImage:[[cell imageView] image]];
    [imageView setFrame:[self.view bounds]];
    [imageView setTag:kTemporaryImageTag];
    [imageView setBackgroundColor:[UIColor blackColor]];
    [imageView setContentMode:[[cell imageView] contentMode]];
    [imageView setAutoresizingMask:0xff];
    [self.view insertSubview:imageView aboveSubview:self.collectionView];

    // Invalidate layout and calculate (next) contentOffset.
    contentOffsetAfterRotation = CGPointMake(indexPath.item * [self.view bounds].size.height, 0);
    [[self.collectionView collectionViewLayout] invalidateLayout];
}

Note that my subclass of UICollectionViewCell has a public imageView property.

3) Finally, the last step is to "snap" the content offset to a valid page and remove the temporary imageview.

- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation
{
    [self.collectionView setContentOffset:contentOffsetAfterRotation];
    [[self.view viewWithTag:kTemporaryImageTag] removeFromSuperview];
}

Solution 3

The "just snap" answer above didn't work for me as it frequently didn't end on the item that was in view before the rotate. So I derived a flow layout that uses a focus item (if set) for calculating the content offset. I set the item in willAnimateRotationToInterfaceOrientation and clear it in didRotateFromInterfaceOrientation. The inset adjustment seems to be need on IOS7 because the Collection view can layout under the top bar.

@interface HintedFlowLayout : UICollectionViewFlowLayout
@property (strong)NSIndexPath* pathForFocusItem;
@end

@implementation HintedFlowLayout

-(CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset
{
    if (self.pathForFocusItem) {
        UICollectionViewLayoutAttributes* layoutAttrs = [self layoutAttributesForItemAtIndexPath:self.pathForFocusItem];
        return CGPointMake(layoutAttrs.frame.origin.x - self.collectionView.contentInset.left, layoutAttrs.frame.origin.y-self.collectionView.contentInset.top);
    }else{
        return [super targetContentOffsetForProposedContentOffset:proposedContentOffset];
    }
}
@end

Solution 4

Swift 4.2 subclass:

class RotatableCollectionViewFlowLayout: UICollectionViewFlowLayout {

    private var focusedIndexPath: IndexPath?

    override func prepare(forAnimatedBoundsChange oldBounds: CGRect) {
        super.prepare(forAnimatedBoundsChange: oldBounds)
        focusedIndexPath = collectionView?.indexPathsForVisibleItems.first
    }

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        guard let indexPath = focusedIndexPath
            , let attributes = layoutAttributesForItem(at: indexPath)
            , let collectionView = collectionView else {
                return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
        }
        return CGPoint(x: attributes.frame.origin.x - collectionView.contentInset.left,
                       y: attributes.frame.origin.y - collectionView.contentInset.top)
    }

    override func finalizeAnimatedBoundsChange() {
        super.finalizeAnimatedBoundsChange()
        focusedIndexPath = nil
    }
}

Solution 5

For those using iOS 8+, willRotateToInterfaceOrientation and didRotateFromInterfaceOrientation are deprecated.

You should use the following now:

/* 
This method is called when the view controller's view's size is changed by its parent (i.e. for the root view controller when its window rotates or is resized). 
If you override this method, you should either call super to propagate the change to children or manually forward the change to children.
*/
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator 
{
    [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];

    [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> context) {
        // Update scroll position during rotation animation
        self.collectionView.contentOffset = (CGPoint){contentOffsetX, contentOffsetY};
    } completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {
        // Whatever you want to do when the rotation animation is done
    }];
}

Swift 3:

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

    coordinator.animate(alongsideTransition: { (context:UIViewControllerTransitionCoordinatorContext) in
        // Update scroll position during rotation animation
    }) { (context:UIViewControllerTransitionCoordinatorContext) in
        // Whatever you want to do when the rotation animation is done
    }
}
Share:
41,138
Tobias Kräntzer
Author by

Tobias Kräntzer

Updated on July 05, 2022

Comments

  • Tobias Kräntzer
    Tobias Kräntzer almost 2 years

    I'm trying to handle interface orientation changes in a UICollectionViewController. What I'm trying to achieve is, that I want to have the same contentOffset after an interface rotation. Meaning, that it should be changed corresponding to the ratio of the bounds change.

    Starting in portrait with a content offset of {bounds.size.width * 2, 0} …

    UICollectionView in portait

    … should result to the content offset in landscape also with {bounds.size.width * 2, 0} (and vice versa).

    UICollectionView in landscape

    Calculating the new offset is not the problem, but don't know, where (or when) to set it, to get a smooth animation. What I'm doing so fare is invalidating the layout in willRotateToInterfaceOrientation:duration: and resetting the content offset in didRotateFromInterfaceOrientation::

    - (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation
                                    duration:(NSTimeInterval)duration;
    {
        self.scrollPositionBeforeRotation = CGPointMake(self.collectionView.contentOffset.x / self.collectionView.contentSize.width,
                                                        self.collectionView.contentOffset.y / self.collectionView.contentSize.height);
        [self.collectionView.collectionViewLayout invalidateLayout];
    }
    
    - (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation;
    {
        CGPoint newContentOffset = CGPointMake(self.scrollPositionBeforeRotation.x * self.collectionView.contentSize.width,
                                               self.scrollPositionBeforeRotation.y * self.collectionView.contentSize.height);
        [self.collectionView newContentOffset animated:YES];
    }
    

    This changes the content offset after the rotation.

    How can I set it during the rotation? I tried to set the new content offset in willAnimateRotationToInterfaceOrientation:duration: but this results into a very strange behavior.

    An example can be found in my Project on GitHub.

  • Daniel Galasko
    Daniel Galasko over 9 years
    don't forget that the rotation API was deprecated as of iOS 8]
  • Valeriy Van
    Valeriy Van over 9 years
    Your answer is brilliant! But you missed calls to super in willRotateToInterfaceOrientation and didRotateFromInterfaceOrientation.
  • mwright
    mwright over 8 years
    This didn't work for me as it was, I instead changed it to not calculate an offset after rotation, instead I store the NSIndexPath pulled when getting cell information and instead of setting the offset I scroll to that with no animation. If someone has interest I can put together an actual answer with code (swift).
  • mwright
    mwright over 8 years
    I ended up using something like this as well, wish I'd seen yours first
  • Alex Cio
    Alex Cio over 8 years
    Please describe what you have done or tell anything about your project!
  • Just a coder
    Just a coder about 8 years
    by far the simplest solution
  • Just a coder
    Just a coder about 7 years
    is the call to super needed though?
  • Kenan Begić
    Kenan Begić almost 7 years
    Exact and precise answer. Thanks a lot!
  • iDoc
    iDoc over 6 years
    After quite a bit of research this is the most elegant solution!
  • iTux
    iTux over 6 years
    How about fullscreen cells ? When I slide to second or third cell, and rotate I see previous cell on last, and last on second :(
  • Nathan Barreto
    Nathan Barreto almost 6 years
    It works. You just have to make sure that viewWillTransition is being called. Other point is if the next page is not a "entire" page you just need to take of the round on index variable.
  • Patrick Roberts
    Patrick Roberts almost 6 years
    I implemented this in Xamarin, works great in iOS 11
  • Mario
    Mario about 5 years
    Should you be using the floor function instead of round?
  • Gurjinder Singh
    Gurjinder Singh about 5 years
    @Mario I am using round only
  • Daniel Williams
    Daniel Williams over 4 years
    This is more what I was looking for. I wanted rotation code to be contained within my custom UICollectionView subclass, so that rotation code wouldn't need to be duplicated throughout the app. This does cause scrolling to occur though, which means cell re-use kicks in. That may or may not have unintentional side-effects.
  • dronpopdev
    dronpopdev about 4 years
    Instead of calling collectionView.reloadData() you can call collectionView.collectionViewLayout.invalidateLayout()
  • aheze
    aheze over 2 years
    Unfortunately, targetContentOffset isn't called for me