Keep zoomable image in center of UIScrollView

In my iPhone app, I need to provide the user with an ability to zoom/pan a large-ish image on the screen. This is quite simple: I use UIScrollView, set max/min scale factors and zooming/panning works as expected. Here’s where things get interesting. The image is a dynamic one, received from a server. It can have any dimensions. When the image first loads, it’s scaled down (if needed) to fit completely into the UIScrollView and is centered in the scroll view – the screenshot is below:

enter image description here

  • How to create a UIImage with UIBezierPath
  • How do you get positional audio to work in SceneKit?
  • How do I do a Fade/No transition between view controllers
  • Create a Paging UICollectionView with Swift
  • Checking if NSString is Integer
  • SpringWithDamping for CALayer animations?
  • Because the proportions of the image are different from those of the scroll view, there’s white space added above and below the image so that the image is centered. However when I start zooming the image, the actual image becomes large enough to fill the whole of the scrollview viewport, therefore white paddings at top/bottom are not needed anymore, however they remain there, as can be seen from this screenshot:

    enter image description here

    I believe this is due to the fact that the UIImageView containing the image is automatically sized to fill the whole of UIScrollView and when zoomed, it just grows proportionally. It has scale mode set to Aspect Fit. UIScrollView‘s delegate viewForZoomingInScrollView simply returns the image view.

    I attempted to recalculate and re-set UIScrollView, contentSize and image view’s size in scrollViewDidEndZooming method:

    CGSize imgViewSize = imageView.frame.size;
    CGSize imageSize = imageView.image.size;
    
    CGSize realImgSize;
    if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
        realImgSize = CGSizeMake(imgViewSize.width, imgViewSize.width / imageSize.width * imageSize.height);
    }
    else {
        realImgSize = CGSizeMake(imgViewSize.height / imageSize.height * imageSize.width, imgViewSize.height);
    }
    scrollView.contentSize = realImgSize;
    
    CGRect fr = CGRectMake(0, 0, 0, 0);
    fr.size = realImgSize;
    imageView.frame = fr;
    

    However this was only making things worse (with bounds still being there but panning not working in the vertical direction).

    Is there any way to automatically reduce that whitespace as it becomes unneeded and then increment again during zoom-in? I suspect the work will need to be done in scrollViewDidEndZooming, but I’m not too sure what that code needs to be.

    5 Solutions Collect From Internet About “Keep zoomable image in center of UIScrollView”

    Awesome!

    Thanks for the code 🙂

    Just thought I’d add to this as I changed it slightly to improve the behaviour.

    // make the change during scrollViewDidScroll instead of didEndScrolling...
    -(void)scrollViewDidZoom:(UIScrollView *)scrollView
    {
        CGSize imgViewSize = self.imageView.frame.size;
        CGSize imageSize = self.imageView.image.size;
    
        CGSize realImgSize;
        if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
            realImgSize = CGSizeMake(imgViewSize.width, imgViewSize.width / imageSize.width * imageSize.height);
        }
        else {
            realImgSize = CGSizeMake(imgViewSize.height / imageSize.height * imageSize.width, imgViewSize.height);
        }
    
        CGRect fr = CGRectMake(0, 0, 0, 0);
        fr.size = realImgSize;
        self.imageView.frame = fr;
    
        CGSize scrSize = scrollView.frame.size;
        float offx = (scrSize.width > realImgSize.width ? (scrSize.width - realImgSize.width) / 2 : 0);
        float offy = (scrSize.height > realImgSize.height ? (scrSize.height - realImgSize.height) / 2 : 0);
    
        // don't animate the change.
        scrollView.contentInset = UIEdgeInsetsMake(offy, offx, offy, offx);
    }
    

    Here’s my solution that works universally with any tab bar or navigation bar combination or w/o both, translucent or not.

    - (void)scrollViewDidZoom:(UIScrollView *)scrollView {
      // The scroll view has zoomed, so you need to re-center the contents
      CGSize scrollViewSize = [self scrollViewVisibleSize];
      // First assume that image center coincides with the contents box center.
      // This is correct when the image is bigger than scrollView due to zoom
      CGPoint imageCenter = CGPointMake(self.scrollView.contentSize.width/2.0,
                                        self.scrollView.contentSize.height/2.0);
    
      CGPoint scrollViewCenter = [self scrollViewCenter];
    
      //if image is smaller than the scrollView visible size - fix the image center accordingly
      if (self.scrollView.contentSize.width < scrollViewSize.width) {
        imageCenter.x = scrollViewCenter.x;
      }
    
      if (self.scrollView.contentSize.height < scrollViewSize.height) {
        imageCenter.y = scrollViewCenter.y;
      }
    
      self.imageView.center = imageCenter;
    }
    
    
    //return the scroll view center
    - (CGPoint)scrollViewCenter {
      CGSize scrollViewSize = [self scrollViewVisibleSize];
      return CGPointMake(scrollViewSize.width/2.0, scrollViewSize.height/2.0);
    }
    
    
    // Return scrollview size without the area overlapping with tab and nav bar.
    - (CGSize) scrollViewVisibleSize {
      UIEdgeInsets contentInset = self.scrollView.contentInset;
      CGSize scrollViewSize = CGRectStandardize(self.scrollView.bounds).size;
      CGFloat width = scrollViewSize.width - contentInset.left - contentInset.right;
      CGFloat height = scrollViewSize.height - contentInset.top - contentInset.bottom;
      return CGSizeMake(width, height);
    }
    

    Why it’s better than anything else I could find on SO so far:

    1. It doesn’t read or modify the UIView frame property of the image view since a zoomed image view has a transform applied to it. See here what Apple says on how to move or adjust a view size when a non identity transform is applied.

    2. Starting iOS 7 where translucency for bars was introduced the system will auto adjust the scroll view size, scroll content insets and scroll indicators offsets. Thus you should not modify these in your code as well.

    FYI:
    There’re check boxes for toggling this behavior (which is set by default) in the Xcode interface builder. You can find it in the view controller attributes:

    automatic scroll view adjustments

    The full view controller’s source code is published here.

    Also you can download the whole Xcode project to see the scroll view constraints setup and play around with 3 different presets in the storyboard by moving the initial controller pointer to any the following paths:

    1. View with both translucent tab and nav bars.
    2. View with both opaque tab and nav bars.
    3. View with no bars at all.

    Every option works correctly with the same VC implementation.

    I think I got it. The solution is to use the scrollViewDidEndZooming method of the delegate and in that method set contentInset based on the size of the image. Here’s what the method looks like:

    - (void)scrollViewDidEndZooming:(UIScrollView *)aScrollView withView:(UIView *)view atScale:(float)scale {
        CGSize imgViewSize = imageView.frame.size;
        CGSize imageSize = imageView.image.size;
    
        CGSize realImgSize;
        if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
            realImgSize = CGSizeMake(imgViewSize.width, imgViewSize.width / imageSize.width * imageSize.height);
        }
        else {
            realImgSize = CGSizeMake(imgViewSize.height / imageSize.height * imageSize.width, imgViewSize.height);
        }
    
        CGRect fr = CGRectMake(0, 0, 0, 0);
        fr.size = realImgSize;
        imageView.frame = fr;
    
        CGSize scrSize = scrollView.frame.size;
        float offx = (scrSize.width > realImgSize.width ? (scrSize.width - realImgSize.width) / 2 : 0);
        float offy = (scrSize.height > realImgSize.height ? (scrSize.height - realImgSize.height) / 2 : 0);
        [UIView beginAnimations:nil context:nil];
        [UIView setAnimationDuration:0.25];
        scrollView.contentInset = UIEdgeInsetsMake(offy, offx, offy, offx);
        [UIView commitAnimations];
    }
    

    Note that I’m using animation on setting the inset, otherwise the image jumps inside the scrollview when the insets are added. With animation it slides to the center. I’m using UIView beginAnimation and commitAnimation instead of animation block, because I need to have the app run on iphone 3.

    Here is the swift 3 version of Genk’s Answer

        func scrollViewDidZoom(_ scrollView: UIScrollView){
            let imgViewSize:CGSize! = self.imageView.frame.size;
            let imageSize:CGSize! = self.imageView.image?.size;
            var realImgSize : CGSize;
            if(imageSize.width / imageSize.height > imgViewSize.width / imgViewSize.height) {
                realImgSize = CGSize(width: imgViewSize.width,height: imgViewSize.width / imageSize.width * imageSize.height);
            }
            else {
                realImgSize = CGSize(width: imgViewSize.height / imageSize.height * imageSize.width, height: imgViewSize.height);
            }
            var fr:CGRect = CGRect.zero
            fr.size = realImgSize;
            self.imageView.frame = fr;
    
            let scrSize:CGSize = scrollView.frame.size;
            let offx:CGFloat = (scrSize.width > realImgSize.width ? (scrSize.width - realImgSize.width) / 2 : 0);
            let offy:CGFloat = (scrSize.height > realImgSize.height ? (scrSize.height - realImgSize.height) / 2 : 0);
            scrollView.contentInset = UIEdgeInsetsMake(offy, offx, offy, offx);
    
            // The scroll view has zoomed, so you need to re-center the contents
            let scrollViewSize:CGSize = self.scrollViewVisibleSize();
    
            // First assume that image center coincides with the contents box center.
            // This is correct when the image is bigger than scrollView due to zoom
            var imageCenter:CGPoint = CGPoint(x: self.scrollView.contentSize.width/2.0, y:
                                              self.scrollView.contentSize.height/2.0);
    
            let scrollViewCenter:CGPoint = self.scrollViewCenter()
    
            //if image is smaller than the scrollView visible size - fix the image center accordingly
            if (self.scrollView.contentSize.width < scrollViewSize.width) {
                imageCenter.x = scrollViewCenter.x;
            }
    
            if (self.scrollView.contentSize.height < scrollViewSize.height) {
                imageCenter.y = scrollViewCenter.y;
            }
    
            self.imageView.center = imageCenter;
    
        }
        //return the scroll view center
        func scrollViewCenter() -> CGPoint {
            let scrollViewSize:CGSize = self.scrollViewVisibleSize()
            return CGPoint(x: scrollViewSize.width/2.0, y: scrollViewSize.height/2.0);
        }
        // Return scrollview size without the area overlapping with tab and nav bar.
        func scrollViewVisibleSize() -> CGSize{
    
            let contentInset:UIEdgeInsets = self.scrollView.contentInset;
            let scrollViewSize:CGSize = self.scrollView.bounds.standardized.size;
            let width:CGFloat = scrollViewSize.width - contentInset.left - contentInset.right;
            let height:CGFloat = scrollViewSize.height - contentInset.top - contentInset.bottom;
            return CGSize(width:width, height:height);
        }
    

    Here is an extension tested on Swift 3.1. Just create a separate *.swift file and paste the code below:

    import UIKit
    
    extension UIScrollView {
    
        func applyZoomToImageView() {
            guard let imageView = delegate?.viewForZooming?(in: self) as? UIImageView else { return }
            guard let image = imageView.image else { return }
            guard imageView.frame.size.valid && image.size.valid else { return }
            let size = image.size ~> imageView.frame.size
            imageView.frame.size = size
            self.contentInset = UIEdgeInsets(
                x: self.frame.size.width ~> size.width,
                y: self.frame.size.height ~> size.height
            )
            imageView.center = self.contentCenter
            if self.contentSize.width < self.visibleSize.width {
                imageView.center.x = self.visibleSize.center.x
            }
            if self.contentSize.height < self.visibleSize.height {
                imageView.center.y = self.visibleSize.center.y
            }
        }
    
        private var contentCenter: CGPoint {
            return CGPoint(x: contentSize.width / 2, y: contentSize.height / 2)
        }
    
        private var visibleSize: CGSize {
            let size: CGSize = bounds.standardized.size
            return CGSize(
                width:  size.width - contentInset.left - contentInset.right,
                height: size.height - contentInset.top - contentInset.bottom
            )
        }
    }
    
    fileprivate extension CGFloat {
    
        static func ~>(lhs: CGFloat, rhs: CGFloat) -> CGFloat {
            return lhs > rhs ? (lhs - rhs) / 2 : 0.0
        }
    }
    
    fileprivate extension UIEdgeInsets {
    
        init(x: CGFloat, y: CGFloat) {
            self.bottom = y
            self.left = x
            self.right = x
            self.top = y
        }
    }
    
    fileprivate extension CGSize {
    
        var valid: Bool {
            return width > 0 && height > 0
        }
    
        var center: CGPoint {
            return CGPoint(x: width / 2, y: height / 2)
        }
    
        static func ~>(lhs: CGSize, rhs: CGSize) -> CGSize {
            switch lhs > rhs {
            case true:
                return CGSize(width: rhs.width, height: rhs.width / lhs.width * lhs.height)
            default:
                return CGSize(width: rhs.height / lhs.height * lhs.width, height: rhs.height)
            }
        }
    
        static func >(lhs: CGSize, rhs: CGSize) -> Bool {
            return lhs.width / lhs.height > rhs.width / rhs.height
        }
    }
    

    The way to use:

    extension YOUR_SCROLL_VIEW_DELEGATE: UIScrollViewDelegate {
    
        func viewForZooming(in scrollView: UIScrollView) -> UIView? {
            return YOUR_IMAGE_VIEW
        }
    
        func scrollViewDidZoom(_ scrollView: UIScrollView){
            scrollView.applyZoomToImageView()
        }
    }