Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I position my UIScrollView's image properly when switching orientation?

I'm having a lot of trouble figuring out how best to reposition my UIScrollView's image view (I have a gallery kind of app going right now, similar to Photos.app, specifically when you're viewing a single image) when the orientation switches from portrait to landscape or vice-versa.

I know my best bet is to manipulate the contentOffset property, but I'm not sure what it should be changed to.

I've played around a lot, and it seems like for whatever reason 128 works really well. In my viewWillLayoutSubviews method for my view controller I have:

    if (UIInterfaceOrientationIsLandscape([UIApplication sharedApplication].statusBarOrientation)) {
        CGPoint newContentOffset = self.scrollView.contentOffset;

        if (newContentOffset.x >= 128) {
            newContentOffset.x -= 128.0;
        }
        else {
            newContentOffset.x = 0.0;
        }

        newContentOffset.y += 128.0;

        self.scrollView.contentOffset = newContentOffset;
    }
    else {
        CGPoint newContentOffset = self.scrollView.contentOffset;

        if (newContentOffset.y >= 128) {
            newContentOffset.y -= 128.0;
        }
        else {
            newContentOffset.y = 0.0;
        }

        newContentOffset.x += 128.0;

        self.scrollView.contentOffset = newContentOffset;
    }

And it works pretty well. I hate how it's using a magic number though, and I have no idea where this would come from.

Also, whenever I zoom the image I have it set to stay centred (just like Photos.app does):

- (void)centerScrollViewContent {
    // Keep image view centered as user zooms
    CGRect newImageViewFrame = self.imageView.frame;

    // Center horizontally
    if (newImageViewFrame.size.width < CGRectGetWidth(self.scrollView.bounds)) {
        newImageViewFrame.origin.x = (CGRectGetWidth(self.scrollView.bounds) - CGRectGetWidth(self.imageView.frame)) / 2;
    }
    else {
        newImageViewFrame.origin.x = 0;
    }

    // Center vertically
    if (newImageViewFrame.size.height < CGRectGetHeight(self.scrollView.bounds)) {
        newImageViewFrame.origin.y = (CGRectGetHeight(self.scrollView.bounds) - CGRectGetHeight(self.imageView.frame)) / 2;
    }
    else {
        newImageViewFrame.origin.y = 0;
    }

    self.imageView.frame = newImageViewFrame;
}

So I need it to keep it positioned properly so it doesn't show black borders around the image when repositioned. (That's what the checks in the first block of code are for.)

Basically, I'm curious how to implement functionality like in Photos.app, where on rotate the scrollview intelligently repositions the content so that the middle of the visible content before the rotation is the same post-rotation, so it feels continuous.

like image 530
Doug Smith Avatar asked Mar 22 '14 19:03

Doug Smith


1 Answers

You should change the UIScrollView's contentOffset property whenever the scrollView is layouting its subviews after its bounds value has been changed. Then when the interface orientation will be changed, UIScrollView's bounds will be changed accordingly updating the contentOffset.

To make things "right" you should subclass UIScrollView and make all the adjustments there. This will also allow you to easily reuse your "special" scrollView.

The contentOffset calculation function should be placed inside UIScrollView's layoutSubviews method. The problem is that this method is called not only when the bounds value is changed but also when srollView is zoomed or scrolled. So the bounds value should be tracked to hint if the layoutSubviews method is called due to a change in bounds as a consequence of the orientation change, or due to a pan or pinch gesture.

So the first part of the UIScrollView subclass should look like this:

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        // Set the prevBoundsSize to the initial bounds, so the first time
        // layoutSubviews is called we won't do any contentOffset adjustments
        self.prevBoundsSize = self.bounds.size;
    }
    return self;
}


- (void)layoutSubviews {
    [super layoutSubviews];

    if (!CGSizeEqualToSize(self.prevBoundsSize, self.bounds.size)) {
        [self _adjustContentOffset];
        self.prevBoundsSize = self.bounds.size;
    }

    [self _centerScrollViewContent];
}

Here, the layoutSubviews method is called every time the UIScrollView is panned, zoomed or its bounds are changed. The _centerScrollViewContent method is responsible for centering the zoomed view when its size becomes smaller than the size of the scrollView's bounds. And, it is called every time user pans or zooms the scrollView, or rotates the device. Its implementation is very similar to the implementation you provided in your question. The difference is that this method is written in the context of UIScrollView class and therefore instead of using self.imageView property to reference the zoomed view, which may not be available in the context of UIScrollView class, the viewForZoomingInScrollView: delegate method is used.

- (void)_centerScrollViewContent {
    if ([self.delegate respondsToSelector:@selector(viewForZoomingInScrollView:)]) {
        UIView *zoomView = [self.delegate viewForZoomingInScrollView:self];

        CGRect frame = zoomView.frame;
        if (self.contentSize.width < self.bounds.size.width) {
            frame.origin.x = roundf((self.bounds.size.width - self.contentSize.width) / 2);
        } else {
            frame.origin.x = 0;
        }
        if (self.contentSize.height < self.bounds.size.height) {
            frame.origin.y = roundf((self.bounds.size.height - self.contentSize.height) / 2);
        } else {
            frame.origin.y = 0;
        }
        zoomView.frame = frame;
    }
}

But the more important thing here is the _adjustContentOffset method. This method is responsible for adjusting the contentOffset. Such that when UIScrollView's bounds value is changed the center point before the change will remain in center. And because of the condition statement, it is called only when UIScrollView's bounds is changed (e.g.: orientation change).

- (void)_adjustContentOffset {
    if ([self.delegate respondsToSelector:@selector(viewForZoomingInScrollView:)]) {
        UIView *zoomView = [self.delegate viewForZoomingInScrollView:self];

        // Using contentOffset and bounds values before the bounds were changed (e.g.: interface orientation change),
        // find the visible center point in the unscaled coordinate space of the zooming view.
        CGPoint prevCenterPoint = (CGPoint){
            .x = (self.prevContentOffset.x + roundf(self.prevBoundsSize.width / 2) - zoomView.frame.origin.x) / self.zoomScale,
            .y = (self.prevContentOffset.y + roundf(self.prevBoundsSize.height / 2) - zoomView.frame.origin.y) / self.zoomScale,
        };

        // Here you can change zoomScale if required
        // [self _changeZoomScaleIfNeeded];

        // Calculate new contentOffset using the previously calculated center point and the new contentOffset and bounds values.
        CGPoint contentOffset = CGPointMake(0.0, 0.0);
        CGRect frame = zoomView.frame;
        if (self.contentSize.width > self.bounds.size.width) {
            frame.origin.x = 0;
            contentOffset.x = prevCenterPoint.x * self.zoomScale - roundf(self.bounds.size.width / 2);
            if (contentOffset.x < 0) {
                contentOffset.x = 0;
            } else if (contentOffset.x > self.contentSize.width - self.bounds.size.width) {
                contentOffset.x = self.contentSize.width - self.bounds.size.width;
            }
        }
        if (self.contentSize.height > self.bounds.size.height) {
            frame.origin.y = 0;
            contentOffset.y = prevCenterPoint.y * self.zoomScale - roundf(self.bounds.size.height / 2);
            if (contentOffset.y < 0) {
                contentOffset.y = 0;
            } else if (contentOffset.y > self.contentSize.height - self.bounds.size.height) {
                contentOffset.y = self.contentSize.height - self.bounds.size.height;
            }
        }
        zoomView.frame = frame;
        self.contentOffset = contentOffset;
    }
}

Bonus

I've created a working SMScrollView class (here is link to GitHub) implementing the above behavior and additional bonuses:

  • You can notice that in Photos app, zooming a photo, then scrolling it to one of its boundaries and then rotating the device does not keep the center point in its place. Instead it sticks the scrollView to that boundary. And if you scroll to one of the corners and then rotate, the scrollView will be stick to that corner as well.
  • In addition to adjusting contentOffset you may find that you also want to adjust the scrollView's zoomScale. For example, assume you are viewing a photo in portrait mode that is scaled to fit the screen size. Then when you rotate the device to the landscape mode you may want to upscale the photo to take advantage of the available space.
like image 135
smnh Avatar answered Oct 12 '22 17:10

smnh