-->

Keep zoomable image in center of UIScrollView

2020-02-08 21:31发布

问题:

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:

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:

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.

回答1:

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);
}


回答2:

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);
}

Swift 5:

public func scrollViewDidZoom(_ scrollView: UIScrollView) {
    centerScrollViewContents()
}

private var scrollViewVisibleSize: CGSize {
    let contentInset = scrollView.contentInset
    let scrollViewSize = scrollView.bounds.standardized.size
    let width = scrollViewSize.width - contentInset.left - contentInset.right
    let height = scrollViewSize.height - contentInset.top - contentInset.bottom
    return CGSize(width:width, height:height)
}

private var scrollViewCenter: CGPoint {
    let scrollViewSize = self.scrollViewVisibleSize()
    return CGPoint(x: scrollViewSize.width / 2.0,
                   y: scrollViewSize.height / 2.0)
}

private func centerScrollViewContents() {
    guard let image = imageView.image else {
        return
    }

    let imgViewSize = imageView.frame.size
    let imageSize = 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 frame = CGRect.zero
    frame.size = realImgSize
    imageView.frame = frame

    let screenSize  = scrollView.frame.size
    let offx = screenSize.width > realImgSize.width ? (screenSize.width - realImgSize.width) / 2 : 0
    let offy = screenSize.height > realImgSize.height ? (screenSize.height - realImgSize.height) / 2 : 0
    scrollView.contentInset = UIEdgeInsets(top: offy,
                                           left: offx,
                                           bottom: offy,
                                           right: offx)

    // The scroll view has zoomed, so you need to re-center the contents
    let scrollViewSize = 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(x: scrollView.contentSize.width / 2.0,
                              y: scrollView.contentSize.height / 2.0)

    let center = scrollViewCenter

    //if image is smaller than the scrollView visible size - fix the image center accordingly
    if scrollView.contentSize.width < scrollViewSize.width {
        imageCenter.x = center.x
    }

    if scrollView.contentSize.height < scrollViewSize.height {
        imageCenter.y = center.y
    }

    imageView.center = imageCenter
}

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:

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.



回答3:

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.



回答4:

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);
    }


回答5:

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()
    }
}