iosuiviewcontrolleruiscrollviewuiimageviewuiscrollviewdelegate

UIScrollView - when is contentSize set


I have a UIViewController and it's view hierarchy looks like this:

I have code that positions the image view in the middle of the scroll view's frame, like so:

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

- (void)recenterContent:(UIScrollView *)scrollView {
    //this centers the content when it is smaller than the scrollView's bounds
    CGFloat offsetX = MAX((scrollView.bounds.size.width - scrollView.contentSize.width) * 0.5, 0.0);
    CGFloat offsetY = MAX((scrollView.bounds.size.height - scrollView.contentSize.height) * 0.5, 0.0);
    
    self.scrollView.contentInset = UIEdgeInsetsMake(offsetY, offsetX, 0.f, 0.f);
}

This works fine when zooming the content, but when the view controller first loads it does not center. This is because the scrollView.contentSize is always 0. So my question is - when should I call this method after the scrollView.contentSize is set? When does that get set?

I have tried in viewDidLayoutSubviews, and the bounds of the scroll view is set then, but not the content size. Is there some method that I can use where the scroll view will be guaranteed to have the content size set?

Or is there a better way to keep the image centered when it is smaller than the scroll view? What I am trying to accomplish is to have it so the image view is not at the top of the scroll view and what I am using works, except when the scroll view's content size is not set. But if there is a better way of doing this without having to adjust the contentInset, I would be fine with that too.


Update

Here is what I have currently.

enter image description here

It is almost working, but no matter what I try, I cannot get it to look correct when the view loads. The way it works now is that it starts out off-center because when it calls the recenterContent method, before the view is displayed the content size of the scroll view is CGSizeZero, so the calculations are wrong. But if I try to recenter the content after the view has been displayed, then there is a visible delay before it gets centered.

I am just confused as to when the contentSize of the scroll view is set if I am using AutoLayout constraints to specify the size.

Here is my code. Can anyone see anything wrong with it?

@interface MyImageViewController ()

@property (strong, nonatomic) UIScrollView *scrollView;
@property (strong, nonatomic) UIImageView *imageView;
@property (assign, nonatomic) BOOL needsZoomScale;

@end

@implementation MyImageViewController

- (void)loadView {
    self.view = [[UIView alloc] init];
    [self.view addSubview:self.scrollView];
    [self.scrollView addSubview:self.imageView];
    
    self.needsZoomScale = YES;
    
    [NSLayoutConstraint activateConstraints:@[
        [self.scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
        [self.scrollView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
        [self.scrollView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
        [self.scrollView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
        
        [self.imageView.leadingAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.leadingAnchor],
        [self.imageView.topAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.topAnchor],
        [self.imageView.trailingAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.trailingAnchor],
        [self.imageView.bottomAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.bottomAnchor]
    ]];
}

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UITapGestureRecognizer *doubleTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(doubleTapZoom:)];
    doubleTapGesture.numberOfTapsRequired = 2;
    [self.imageView addGestureRecognizer:doubleTapGesture];
}

- (CGRect)zoomRectForScrollView:(UIScrollView *)scrollView withScale:(CGFloat)scale withCenter:(CGPoint)center {
    CGRect zoomRect;
    
    //the zoom rect is in the content view's coordinates. At a zoom scale of 1.0, the zoom rect would be the size
    //of the scroll view's bounds. As the zoom scale decreases, so more content is visible, the size of the rect
    //grows.
    zoomRect.size.width = scrollView.frame.size.width / scale;
    zoomRect.size.height = scrollView.frame.size.height / scale;
    
    //choose an origin so as to get the right center
    zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0);
    zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0);
    
    return zoomRect;
}

- (void)doubleTapZoom:(UITapGestureRecognizer *)sender {
    UIView *tappedView = sender.view;
    CGPoint tappedPoint = [sender locationInView:tappedView];
    
    if (tappedPoint.x <= 0) {
        tappedPoint.x = 1;
    }
    
    if (tappedPoint.y <= 0) {
        tappedPoint.y = 1;
    }
    
    if (tappedPoint.x >= tappedView.bounds.size.width) {
        tappedPoint.x = tappedView.bounds.size.width - 1;
    }
    
    if (tappedPoint.y >= tappedView.bounds.size.height) {
        tappedPoint.y = tappedView.bounds.size.height - 1;
    }

    CGFloat zoomScale;
    if (self.scrollView.zoomScale < 1) {
        zoomScale = 1;
    } else if (self.scrollView.zoomScale < self.scrollView.maximumZoomScale) {
        zoomScale = self.scrollView.maximumZoomScale;
    } else {
        zoomScale = self.scrollView.minimumZoomScale;
    }
    
    CGRect zoomRect = [self zoomRectForScrollView:self.scrollView withScale:zoomScale withCenter:tappedPoint];
    
    [self.scrollView zoomToRect:zoomRect animated:YES];
}

- (UIScrollView *)scrollView {
    if (!self->_scrollView) {
        self->_scrollView = [[UIScrollView alloc] init];
        self->_scrollView.translatesAutoresizingMaskIntoConstraints = NO;
        self->_scrollView.minimumZoomScale = 0.1f;
        self->_scrollView.maximumZoomScale = 4.0f;
        self->_scrollView.bounces = YES;
        self->_scrollView.bouncesZoom = YES;
        self->_scrollView.delegate = self;
        self->_scrollView.backgroundColor = [UIColor blackColor];
    }
    return self->_scrollView;
}

- (UIImageView *)imageView {
    if (!self->_imageView) {
        self->_imageView = [[UIImageView alloc] init];
        self->_imageView.translatesAutoresizingMaskIntoConstraints = NO;
        self->_imageView.userInteractionEnabled = YES;
    }
    return self->_imageView;
}

- (UIImage *)image {
    return self.imageView.image;
}

- (void)setImage:(UIImage *)image {
    self.imageView.image = image;
    self.needsZoomScale = YES;
    [self updateZoomScale];
}

- (void)updateZoomScale {
    if (self.needsZoomScale && self.image) {
        CGSize size = self.view.bounds.size;
        
        if (size.width == 0.0f || size.height == 0.0f) {
            return;
        }
            
        UIImage *image = self.image;
        CGSize imageSize = CGSizeMake(image.size.width * image.scale, image.size.height * image.scale);
        if (imageSize.width > 0 && imageSize.height > 0) {
            CGFloat widthScale = size.width / imageSize.width;
            CGFloat heightScale = size.height / imageSize.height;
            CGFloat minScale = MIN(widthScale, heightScale);
                
            self.scrollView.minimumZoomScale = minScale;
            self.scrollView.zoomScale = minScale;
            self.needsZoomScale = NO;
        }
    }
}

- (void)viewWillLayoutSubviews {
    [super viewWillLayoutSubviews];
    [self updateZoomScale];
}

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];
    [self recenterContent:self.scrollView];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    [self recenterContent:self.scrollView];
}

#pragma mark - UIScrollViewDelegate

- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
    return self.imageView;
}

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

- (void)recenterContent:(UIScrollView *)scrollView {
    //this centers the content when it is smaller than the scrollView's bounds
    CGFloat offsetX = MAX((scrollView.bounds.size.width - scrollView.contentSize.width) * 0.5, 0.0);
    CGFloat offsetY = MAX((scrollView.bounds.size.height - scrollView.contentSize.height) * 0.5, 0.0);
    
    self.scrollView.contentInset = UIEdgeInsetsMake(offsetY, offsetX, 0.f, 0.f);
}

@end

Solution

  • The problem is that a UIImageView has an intrinsic content size of 0,0 -- so your code is initially putting the a 0x0 image view at the center of the scroll view.

    I've made a few changes to the code you posted... see comments (I "wrapped" the changes in

    // ---------------------------------
    

    comment lines:

    @interface MyImageViewController : UIViewController <UIScrollViewDelegate>
    @end
    
    @interface MyImageViewController ()
    
    @property (strong, nonatomic) UIScrollView *scrollView;
    @property (strong, nonatomic) UIImageView *imageView;
    @property (assign, nonatomic) BOOL needsZoomScale;
    
    @end
    
    @implementation MyImageViewController
    
    - (void)loadView {
        self.view = [[UIView alloc] init];
        [self.view addSubview:self.scrollView];
        [self.scrollView addSubview:self.imageView];
    
        self.needsZoomScale = YES;
        
        // ---------------------------------
        //  respect safe area
        UILayoutGuide *g = [self.view safeAreaLayoutGuide];
        //  saves on a little typing
        UILayoutGuide *sg = [self.scrollView contentLayoutGuide];
        // ---------------------------------
    
        [NSLayoutConstraint activateConstraints:@[
            [self.scrollView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor],
            [self.scrollView.topAnchor constraintEqualToAnchor:g.topAnchor],
            [self.scrollView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor],
            [self.scrollView.bottomAnchor constraintEqualToAnchor:g.bottomAnchor],
            
            [self.imageView.leadingAnchor constraintEqualToAnchor:sg.leadingAnchor],
            [self.imageView.topAnchor constraintEqualToAnchor:sg.topAnchor],
            [self.imageView.trailingAnchor constraintEqualToAnchor:sg.trailingAnchor],
            [self.imageView.bottomAnchor constraintEqualToAnchor:sg.bottomAnchor]
        ]];
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        UITapGestureRecognizer *doubleTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(doubleTapZoom:)];
        doubleTapGesture.numberOfTapsRequired = 2;
        [self.imageView addGestureRecognizer:doubleTapGesture];
    }
    
    - (CGRect)zoomRectForScrollView:(UIScrollView *)scrollView withScale:(CGFloat)scale withCenter:(CGPoint)center {
        CGRect zoomRect;
        
        //the zoom rect is in the content view's coordinates. At a zoom scale of 1.0, the zoom rect would be the size
        //of the scroll view's bounds. As the zoom scale decreases, so more content is visible, the size of the rect
        //grows.
        zoomRect.size.width = scrollView.frame.size.width / scale;
        zoomRect.size.height = scrollView.frame.size.height / scale;
        
        //choose an origin so as to get the right center
        zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0);
        zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0);
        
        return zoomRect;
    }
    
    - (void)doubleTapZoom:(UITapGestureRecognizer *)sender {
        UIView *tappedView = sender.view;
        CGPoint tappedPoint = [sender locationInView:tappedView];
        
        if (tappedPoint.x <= 0) {
            tappedPoint.x = 1;
        }
        
        if (tappedPoint.y <= 0) {
            tappedPoint.y = 1;
        }
        
        if (tappedPoint.x >= tappedView.bounds.size.width) {
            tappedPoint.x = tappedView.bounds.size.width - 1;
        }
        
        if (tappedPoint.y >= tappedView.bounds.size.height) {
            tappedPoint.y = tappedView.bounds.size.height - 1;
        }
        
        CGFloat zoomScale;
        if (self.scrollView.zoomScale < 1) {
            zoomScale = 1;
        } else if (self.scrollView.zoomScale < self.scrollView.maximumZoomScale) {
            zoomScale = self.scrollView.maximumZoomScale;
        } else {
            zoomScale = self.scrollView.minimumZoomScale;
        }
        
        CGRect zoomRect = [self zoomRectForScrollView:self.scrollView withScale:zoomScale withCenter:tappedPoint];
        
        [self.scrollView zoomToRect:zoomRect animated:YES];
    }
    
    - (UIScrollView *)scrollView {
        if (!self->_scrollView) {
            self->_scrollView = [[UIScrollView alloc] init];
            self->_scrollView.translatesAutoresizingMaskIntoConstraints = NO;
            self->_scrollView.minimumZoomScale = 0.1f;
            self->_scrollView.maximumZoomScale = 4.0f;
            self->_scrollView.bounces = YES;
            self->_scrollView.bouncesZoom = YES;
            self->_scrollView.delegate = self;
            self->_scrollView.backgroundColor = [UIColor blackColor];
        }
        return self->_scrollView;
    }
    
    - (UIImageView *)imageView {
        if (!self->_imageView) {
            self->_imageView = [[UIImageView alloc] init];
            self->_imageView.translatesAutoresizingMaskIntoConstraints = NO;
            self->_imageView.userInteractionEnabled = YES;
        }
        return self->_imageView;
    }
    
    - (UIImage *)image {
        return self.imageView.image;
    }
    
    - (void)setImage:(UIImage *)image {
        self.imageView.image = image;
        
        // ---------------------------------
        //  set the frame here
        self.imageView.frame = CGRectMake(0.0, 0.0, image.size.width, image.size.height);
        
        // ---------------------------------
        //  not needed ... unless maybe changing the image while view is showing?
        //self.needsZoomScale = YES;
        //[self updateZoomScale];
    }
    
    - (void)updateZoomScale {
        if (self.needsZoomScale && self.image) {
            CGSize size = self.view.bounds.size;
            
            if (size.width == 0.0f || size.height == 0.0f) {
                return;
            }
            
            UIImage *image = self.image;
            CGSize imageSize = CGSizeMake(image.size.width * image.scale, image.size.height * image.scale);
            if (imageSize.width > 0 && imageSize.height > 0) {
                CGFloat widthScale = size.width / imageSize.width;
                CGFloat heightScale = size.height / imageSize.height;
                CGFloat minScale = MIN(widthScale, heightScale);
                
                self.scrollView.minimumZoomScale = minScale;
                self.scrollView.zoomScale = minScale;
                self.needsZoomScale = NO;
            }
        }
    }
    
    // ---------------------------------
    //  Don't need this
    //- (void)viewWillLayoutSubviews {
    //  [super viewWillLayoutSubviews];
    //  [self updateZoomScale];
    //}
    // ---------------------------------
    
    - (void)viewDidLayoutSubviews {
        [super viewDidLayoutSubviews];
    
        // ---------------------------------
        //  update zoom scale here
        [self updateZoomScale];
        // ---------------------------------
        
        [self recenterContent:self.scrollView];
    }
    
    // ---------------------------------
    //  Don't need this
    //- (void)viewDidAppear:(BOOL)animated {
    //  [super viewDidAppear:animated];
    //  [self recenterContent:self.scrollView];
    //}
    // ---------------------------------
    
    #pragma mark - UIScrollViewDelegate
    
    - (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
        return self.imageView;
    }
    
    - (void)scrollViewDidZoom:(UIScrollView *)scrollView {
        [self recenterContent:scrollView];
    }
    
    - (void)recenterContent:(UIScrollView *)scrollView {
        //this centers the content when it is smaller than the scrollView's bounds
        CGFloat offsetX = MAX((scrollView.bounds.size.width - scrollView.contentSize.width) * 0.5, 0.0);
        CGFloat offsetY = MAX((scrollView.bounds.size.height - scrollView.contentSize.height) * 0.5, 0.0);
        
        self.scrollView.contentInset = UIEdgeInsetsMake(offsetY, offsetX, 0.f, 0.f);
    }
    
    @end
    

    and here's how I call it:

    MyImageViewController *vc = [MyImageViewController new];
    
    UIImage *img = [UIImage imageNamed:@"bkg"];
    if (nil == img) {
        NSLog(@"Could not load image!!!!");
        return;
    }
    [vc setImage:img];
    
    [self.navigationController pushViewController:vc animated:YES];