Blog

Thoughts on mobile app development.

Rotating a Paged UIScrollView

Adding rotation to your app is one of those things that users often expect, but which can really bump up the complexity of development. In most cases autosizing your content works nicely, but it’s not unusual to run into things that just don’t behave quite as you might expect.

I came across one of these recently with a paged UIScrollView. The content was a number of full-screen photos, which allows the user to swipe left and right to change photos. This is a fairly common use of a UIScrollView, and has a nice bang-for-your-buck feeling about it – it’s pretty easy to setup, and results in one of the most natural way to view your photos.

That is, until you enable rotation. The first problem that you’ll likely encounter is that all your pages suddenly overlap each other (or have big spaces between them). This is easily fixed by updating the origin of each page to reflect the new dimensions of the UIScrollView.

The next problem you’ll come across is that the UIScrollView content offset is still in the same position as it was prior to rotation. This usually results in your UIScrollView being stuck between two photos, with only a portion of each photo being visible. If you’re really unlucky, your content offset will be past the new width of the UIScrollView, so that you can’t see anything at all. Either way, as soon as you try to move the content, it will jerk back into the right position for one of the pages (though, not necessarily the right page).

The easiest solution to this is to simply update the content offset once rotation has completed. As long as you know which photo you were viewing before, and what the width of the UIScrollView is, this is easy to do with some simple maths. However, this approach is not as polished as it could be. You still get all the problems mentioned previously, and the only difference is that the UIScrollView jerks into position automatically, rather than when you try and interact with it.

We can do better. The next approach would be to animate changing the content offset. UIViewController exposes a very handy method that lets you know when the rotation is about to occur:

[code]willAnimateRotationToInterfaceOrientation:duration:[/code]

So, we simply make a call to change the content offset inside this method:

[code][self.scrollView setContentOffset:CGPointMake(X,Y) animated:YES];[/code]

However, this doesn’t quite work as expected either. As the rotation animation occurs, you can see the edges of the other photos move in and out. You start and end in the right position, but you shouldn’t see any parts of the other photos as the rotation is going on. I’m not entirely sure why this is – possibly because the content offset animation is handled using the animated: parameter, rather than in a block based animation with the same duration as the rotation. A handy trick when using the iOS Simulator is to tap the Shift key three times. This turns on slow motion animations, and make it’s much easier to see what’s really going on.

The next approach might be to manually update the content offset one pixel at a time, in sync with the rotation animation. This sounds like a nasty hack though, and there’s a better way – it just requires a little thinking outside the box.

The trick is to actually remove the current page from the UIScrollView before rotation, add it to the parent UIView, and hide the UIScrollView. Then, once the rotation has completed, update the UIScrollView content offset (not animated), put the page back into the UIScrollView, update all the frames of the UIScrollView to reflect their new positions, and then unhide the UIScrollView. Something like this:

[code]
- (void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration {
self.scrollView.hidden = YES;

//remove the current PageViewController and place it in the parent view
PageViewController* pageViewController = [self.pageViewControllers objectAtIndex:self.currentPageIndex];
CGRect frame = pageViewController.view.frame;
frame.origin = CGPointZero;
frame.size = kScrollViewLandscapeSize;
if(UIInterfaceOrientationIsLandscape(toInterfaceOrientation)) {
frame.size = kScrollViewPortraitSize;
}
pageViewController.view.frame = frame;
[self.view addSubview:pageViewController.view];

//animate the frame change for the pageViewController
[UIView animateWithDuration:duration animations:^{
CGRect frame = pageViewController.view.frame;
if(UIInterfaceOrientationIsLandscape(toInterfaceOrientation) {
frame.size = kScrollViewLandscapeSize;
}
else {
frame.size = kScrollViewPortraitSize;
}
pageViewController.view.frame = frame;
}

completion:^(BOOL finished) {

CGSize scrollViewSize = [self scrollViewSizeForCurrentOrientation];
[self.scrollView setContentSize:CGSizeMake(scrollViewSize.width*self.tips.count, scrollViewSize.height)];
[self.scrollView setContentOffset:CGPointMake(scrollViewSize.width*self.currentPageIndex, 0)];

//put the pageViewController back in the UIScrollView, and update the frames for all the PageViewControllers inside the UIScrollView
[self.scrollView addSubview:pageViewController.view];

[self.pageViewControllers enumerateObjectsUsingBlock:^(PageViewController* pageViewController, NSUInteger idx, BOOL *stop) {
CGRect frame = pageViewController.view.frame;
frame.origin.x = scrollViewSize.width * idx;
pageViewController.view.frame = frame;
}];
self.scrollView.hidden = NO;
}];
}
}

[/code]

A neat little trick that has the desired effect – smoothly animated rotations that behave just as you’d expect!

2 Comments

  1. A simple solution that works well is to register for notifications when the contentSize of the UIScrollView is changed and then update the contentOffset at that moment. Very clean visuals using this approach.

    In your view loading code:

    [self addObserver:theScrollView forKeyPath:@"contentSize" options:0 context:nil];

    Then catch the notification:

    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
    {
    theScrollView.contentOffset = page * theScrollView.bounds.width;
    }

    In view unload method:

    [self removeObserver:self forKeyPath:@"contentSize"];

    Reply
    • That is a very elegant solution, Tony! It solved my problem. Let me fix a few typos that might confuse people:

      [theScrollView addObserver:self forKeyPath:@"contentSize" options:0 context:nil];

      – (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary*)change context:(void *)context
      {
      theScrollView.contentOffset = CGPointMake(page * theScrollView.bounds.size.width, 0);
      }

      [theScrollView removeObserver:self forKeyPath:@"contentSize"];

      Reply

Submit a Comment

Your email address will not be published. Required fields are marked *