iosuiscrollviewuiimageviewzoomingpinchzoom

UIScrollView automatically scrolls UIImageView to top on pinch-to-zoom


I'm having issues with UIScrollView when doing pinch-to-zoom UIImageView. Implementation is relatively simple which you can find in many places on the Internet. Double tap to zoom in and out works perfectly fine. But pinch to zoom rarely works as expected. The main problem is that sometimes (like in 50% of cases) UIImageView is scrolled to the top although content offset is set to have padding. Easy to reproduce on horizontal images when slightly zoomed in by pinching the image. Can be reproduced on iOS 16 / 17. Appreciate any help.

#import "ViewController.h"
#import <Photos/Photos.h>
#import <PhotosUI/PhotosUI.h>

@interface ViewController () <UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIScrollViewDelegate> {
  UIImageView *_imageView;
  UIScrollView *_scrollView;
}

@property (nonatomic, readonly) BOOL isZoomed;

@end

@implementation ViewController

- (BOOL) isZoomed {
  return _scrollView.zoomScale > _scrollView.minimumZoomScale;
}

- (void) viewDidLayoutSubviews {
  [super viewDidLayoutSubviews];
  
  _scrollView.frame = self.view.bounds;
}

- (void) viewDidLoad {
  [super viewDidLoad];
  // Do any additional setup after loading the view.
  
  _scrollView = [[UIScrollView alloc] initWithFrame: self.view.bounds];
  
  _scrollView.backgroundColor = UIColor.clearColor;
  _scrollView.showsVerticalScrollIndicator = NO;
  _scrollView.showsHorizontalScrollIndicator = NO;
  
  _scrollView.delegate = self;
  _scrollView.minimumZoomScale = 1.0;
  _scrollView.maximumZoomScale = 5.0;
  _scrollView.clipsToBounds = YES;
  _scrollView.scrollsToTop = NO;
  _scrollView.contentInset = UIEdgeInsetsZero;
  _scrollView.translatesAutoresizingMaskIntoConstraints = NO;
  _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
  
  [_scrollView setAutoresizesSubviews: NO];
  
  _imageView = [[UIImageView alloc] initWithFrame: self.view.bounds];
  
  _imageView.backgroundColor = UIColor.clearColor;
  _imageView.translatesAutoresizingMaskIntoConstraints = NO;
  _imageView.contentMode = UIViewContentModeScaleAspectFit;
  _imageView.autoresizingMask = UIViewAutoresizingNone;
  
  [_imageView setUserInteractionEnabled: YES];
  
  [_scrollView addSubview: _imageView];
  [self.view addSubview: _scrollView];
  
  [self.view sendSubviewToBack: _scrollView];
  
  
  UITapGestureRecognizer *_zoomGesture = [[UITapGestureRecognizer alloc] initWithTarget: self action: @selector(onEditorDoubleTap:)];
  
  _zoomGesture.numberOfTapsRequired = 2;
  
  [_scrollView addGestureRecognizer: _zoomGesture];
}

- (void) resetZoom {
  if (!self.isZoomed) {
    return;
  }
    
  [_scrollView setZoomScale: _scrollView.minimumZoomScale animated: YES];
}

- (void) onEditorDoubleTap: (UITapGestureRecognizer *)gestureRecognizer {
  if (gestureRecognizer.state != UIGestureRecognizerStateEnded) {
    return;
  }
  
  if (self.isZoomed) {
    [self resetZoom];
  } else {
    CGPoint location = [gestureRecognizer locationInView: gestureRecognizer.view];
    CGPoint pointInView = [gestureRecognizer.view convertPoint: location toView: _imageView];
    CGFloat newZoomScale = (_scrollView.zoomScale > _scrollView.minimumZoomScale) ? _scrollView.minimumZoomScale : _scrollView.maximumZoomScale;
    
    CGFloat width = _scrollView.frame.size.width / newZoomScale;
    CGFloat height = _scrollView.frame.size.height / newZoomScale;
    CGFloat x = pointInView.x - (width / 2.0);
    CGFloat y = pointInView.y - (height / 2.0);
    CGRect rectToZoomTo = CGRectMake(x, y, width, height);
    
    [_scrollView zoomToRect: rectToZoomTo animated: YES];
  }
}

- (IBAction) openPhoto {
  UIImagePickerController *picker = [[UIImagePickerController alloc] init];
  
  picker.delegate = self;
  picker.allowsEditing = NO;
  picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
  
  [self presentViewController:picker animated:YES completion:nil];
}

- (void)imagePickerController: (UIImagePickerController *) picker didFinishPickingMediaWithInfo: (NSDictionary *) info {
  UIImage *image = info[UIImagePickerControllerOriginalImage];
  
  _imageView.image = image;
  
  _imageView.transform = CGAffineTransformIdentity;
  _imageView.frame = CGRectMake(0, 0, image.size.width, image.size.height);
  
  _scrollView.frame = self.view.bounds;
  _scrollView.contentSize = image.size;
  
  CGSize scrollViewSize = _scrollView.frame.size;
  CGFloat widthScale = scrollViewSize.width / image.size.width;
  CGFloat heightScale = scrollViewSize.height / image.size.height;
  
  _scrollView.minimumZoomScale = MIN(widthScale, heightScale);
  _scrollView.maximumZoomScale = _scrollView.minimumZoomScale * 5.0;
  _scrollView.zoomScale = _scrollView.minimumZoomScale;
  
  [self.view bringSubviewToFront: _scrollView];
  
  __weak __typeof__(self) weakSelf = self;
  
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
    [weakSelf centerImageAnimated: YES];
  });
  
  [picker dismissViewControllerAnimated: YES completion: nil];
}

- (void) centerImageAnimated: (BOOL) animated {
  CGSize scrollViewSize = _scrollView.frame.size;
  CGFloat imageWidth = _scrollView.contentSize.width;
  CGFloat imageHeigth = _scrollView.contentSize.height;
  
  if (imageWidth > scrollViewSize.width && imageHeigth > scrollViewSize.height) {
    return;
  }
    
  CGFloat horizontalPadding = (scrollViewSize.width - imageWidth) * 0.5f;
  CGFloat verticalPadding = (scrollViewSize.height - imageHeigth) * 0.5f;
    
  [_scrollView setContentOffset: CGPointMake(-horizontalPadding, -verticalPadding) animated: animated];
}

#pragma mark - UIScrollViewDelegate

- (UIView *) viewForZoomingInScrollView: (UIScrollView *) scrollView {
  return _imageView;
}

- (void) scrollViewDidZoom: (UIScrollView *) scrollView {
  [self centerImageAnimated: NO];
}

@end


Solution

  • A UIScrollView will allow you to drag past the edges...

    So, for example, if the content is taller than the frame, we can drag-down and see a "gap" between the top of the content and the top of the scroll view frame. At that point, the contentOffset.y becomes negative. As soon as we stop dragging, the scroll view resets contentOffset.y to Zero.

    It will also allow you to explicitly set contentOffset.y to a negative value -- which is what your code is doing to center the content.

    However, as soon as UIKit makes a layout pass, the scroll view resets contentOffset.y to Zero.

    What we need to do is modify the scroll view's .contentInset...

    For example, if we need 100-points of space at the top (and bottom) to vertically center the content, we set the .contentInset.top to 100. The .contentOffset.y will then be Zero but we'll have the needed vertical space.

    Here is your ViewController with a few edits... while this has nothing to do with the layout logic, you'll notice I edited the code so I could add an image without needing to invoke the Image Picker:

    @implementation ViewController
    
    - (BOOL) isZoomed {
        return _scrollView.zoomScale > _scrollView.minimumZoomScale;
    }
    
    - (void)viewDidAppear:(BOOL)animated {
        [super viewDidAppear:animated];
        UIImage *image = [UIImage imageNamed:@"bkg2400x1500"];
        if (image) {
            [self addImage:image];
        }
    }
    
    - (void) viewDidLoad {
        [super viewDidLoad];
        // Do any additional setup after loading the view.
        
        _scrollView = [[UIScrollView alloc] initWithFrame: self.view.bounds];
        
        // let's use a background color so we can see the scroll view
        _scrollView.backgroundColor = UIColor.systemYellowColor;
        _scrollView.showsVerticalScrollIndicator = NO;
        _scrollView.showsHorizontalScrollIndicator = NO;
        
        _scrollView.delegate = self;
        _scrollView.minimumZoomScale = 1.0;
        _scrollView.maximumZoomScale = 5.0;
        _scrollView.clipsToBounds = YES;
        _scrollView.scrollsToTop = NO;
        _scrollView.contentInset = UIEdgeInsetsZero;
        _scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
        
        [_scrollView setAutoresizesSubviews: NO];
        
        _imageView = [[UIImageView alloc] initWithFrame: self.view.bounds];
        
        _imageView.contentMode = UIViewContentModeScaleAspectFit;
        _imageView.autoresizingMask = UIViewAutoresizingNone;
        
        [_imageView setUserInteractionEnabled: YES];
        
        [_scrollView addSubview: _imageView];
        [self.view addSubview: _scrollView];
        
        // let's use autolayout to set the frame of the scrollView
        _scrollView.translatesAutoresizingMaskIntoConstraints = NO;
        
        UIView *g = self.view;
        
        [NSLayoutConstraint activateConstraints:@[
            [_scrollView.topAnchor constraintEqualToAnchor:g.topAnchor constant:0.0],
            [_scrollView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:0.0],
            [_scrollView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:0.0],
            [_scrollView.bottomAnchor constraintEqualToAnchor:g.bottomAnchor constant:0.0],
        ]];
        
        UITapGestureRecognizer *_zoomGesture = [[UITapGestureRecognizer alloc] initWithTarget: self action: @selector(onEditorDoubleTap:)];
        
        _zoomGesture.numberOfTapsRequired = 2;
        
        [_scrollView addGestureRecognizer: _zoomGesture];
    }
    
    - (void) resetZoom {
        if (!self.isZoomed) {
            return;
        }
        
        [_scrollView setZoomScale: _scrollView.minimumZoomScale animated: YES];
    }
    
    - (void) onEditorDoubleTap: (UITapGestureRecognizer *)gestureRecognizer {
        if (gestureRecognizer.state != UIGestureRecognizerStateEnded) {
            return;
        }
        
        if (self.isZoomed) {
            [self resetZoom];
        } else {
            
            CGPoint location = [gestureRecognizer locationInView: gestureRecognizer.view];
            CGPoint pointInView = [gestureRecognizer.view convertPoint: location toView: _imageView];
            CGFloat newZoomScale = (_scrollView.zoomScale > _scrollView.minimumZoomScale) ? _scrollView.minimumZoomScale : _scrollView.maximumZoomScale;
            
            CGFloat width = _scrollView.frame.size.width / newZoomScale;
            CGFloat height = _scrollView.frame.size.height / newZoomScale;
            CGFloat x = pointInView.x - (width / 2.0);
            CGFloat y = pointInView.y - (height / 2.0);
            
            CGRect rectToZoomTo = CGRectMake(x, y, width, height);
            
            [_scrollView zoomToRect: rectToZoomTo animated: YES];
            
        }
    }
    
    - (IBAction) openPhoto {
        UIImagePickerController *picker = [[UIImagePickerController alloc] init];
        
        picker.delegate = self;
        picker.allowsEditing = NO;
        picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
        
        [self presentViewController:picker animated:YES completion:nil];
    }
    
    - (void)imagePickerController: (UIImagePickerController *) picker didFinishPickingMediaWithInfo: (NSDictionary *) info {
        UIImage *image = info[UIImagePickerControllerOriginalImage];
        [self addImage:image];
        [picker dismissViewControllerAnimated: YES completion: nil];
    }
    
    - (void)addImage: (UIImage *)image {
        _imageView.image = image;
        
        _imageView.transform = CGAffineTransformIdentity;
        _imageView.frame = CGRectMake(0, 0, image.size.width, image.size.height);
        
        _scrollView.contentSize = _imageView.frame.size;
    
        CGSize scrollViewSize = _scrollView.frame.size;
        CGFloat widthScale = scrollViewSize.width / image.size.width;
        CGFloat heightScale = scrollViewSize.height / image.size.height;
        
        _scrollView.minimumZoomScale = MIN(widthScale, heightScale);
        _scrollView.maximumZoomScale = _scrollView.minimumZoomScale * 5.0;
        _scrollView.zoomScale = _scrollView.minimumZoomScale;
        
        __weak __typeof__(self) weakSelf = self;
        
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
            [weakSelf centerImageAnimated: YES];
        });
        
    }
    
    - (void) centerImageAnimated: (BOOL) animated {
        CGSize scrollViewSize = _scrollView.frame.size;
        CGFloat imageWidth = _scrollView.contentSize.width;
        CGFloat imageHeigth = _scrollView.contentSize.height;
    
        // we need to update the contentInset every time
        //if (imageWidth > scrollViewSize.width && imageHeigth > scrollViewSize.height) {
        //  return;
        //}
    
        CGFloat w = 0.0;
        CGFloat h = 0.0;
        
        if (imageWidth < scrollViewSize.width) {
            w = scrollViewSize.width - imageWidth;
        }
        if (imageHeigth < scrollViewSize.height) {
            h = scrollViewSize.height - imageHeigth;
        }
        
        CGFloat horizontalPadding = w * 0.5;
        CGFloat verticalPadding = h * 0.5;
        
        // update the contentInset -- NOT the contentOffset
        UIEdgeInsets e = _scrollView.contentInset;
        e.left = MAX(0.0, horizontalPadding);
        e.top = MAX(0.0, verticalPadding);
        [_scrollView setContentInset:e];
    }
    
    #pragma mark - UIScrollViewDelegate
    
    - (UIView *) viewForZoomingInScrollView: (UIScrollView *) scrollView {
        return _imageView;
    }
    
    - (void) scrollViewDidZoom: (UIScrollView *) scrollView {
        [self centerImageAnimated: NO];
    }
    
    @end