How do I programmatically zoom a UIScrollView?
I'd like to zoom and unzoom in ways the base class doesn't support.
For instance, upon receiving a double tap.
Solution 1:
I'm answering my own question, after playing with things and getting it working.
Apple has a very-simple example of this in their documentation on how to handle double taps.
The basic approach to doing programmatic zooms is to do it yourself, and then tell the UIScrollView that you did it.
- Adjust the internal view's frame and bounds.
- Mark the internal view as needing display.
- Tell the UIScrollView about the new content size.
- Calculate the portion of your internal view that should be displayed after the zoom, and have the UIScrollView pan to that location.
Also key: once you tell the UIScrollView about your new contents size it seems to reset its concept of the current zoom level. You are now at the new 1.0 zoom factor. So you'll almost certainly want to reset the minimum and maximum zoom factors.
Solution 2:
Stop reinventing the wheel! See how apple does it!
ScrollViewSuite -> Apple Documentation Page
ScrollViewSuite Direct Link -> XcodeProject
It's exactly what you're lookng for.
Cheers!
Solution 3:
NOTE: this is horribly outdated. It's around from iOS 2.x times, and has actually been fixed around iOS 3.x.
Keeping it here for historical purposes only.
I think I found a clean solution to this, and I made an UIScrollView subclass to encapsulate it.
The sample code that illustrates both programmatic zooming (+ double-tap handling) and Photo Library-style paging+zooming+scrolling, together with ZoomScrollView class, is available at github.com/andreyvit/ScrollingMadness.
In a few words, my solution is to return a new dummy view from viewForZoomingInScrollView:
, temporarily making your content view (UIImageView, whatever) its child. In scrollViewDidEndZooming:
we reverse that, disposing the dummy view and moving your content view back into the scroll view.
Why does it help? It is a way of defeating the persistent view scale that we cannot change programmatically. UIScrollView does not keep the current view scale itself. Instead, each UIView is capable of keeping its current view scale (inside UIGestureInfo object pointed to by the _gestureInfo field). By providing a new UIView for each zooming operation, we always start with zoom scale 1.00.
And how does that help? We store the current zoom scale ourself, and apply it manually to our content view, e.g. contentView.transform = CGAffineTransformMakeScale(zoomScale, zoomScale)
. This however conflicts with UIScrollView wanting to reset the transform when the user pinches the view. By giving UIScrollView another view with identity transform to zoom, we no longer fight for transforming the same view. UIScrollView can happily believe it starts with zoom 1.00 each time and scales a view starting with an identity transform, and its inner view has a transform applied corresponding to our actual current zoom scale.
Now, ZoomScrollView encapsulates all this stuff. Here's its code for the sake of completeness, however I really recommend to download the sample project from GitHub (you don't need to use Git, there's a Download button there). If you want to be notified about sample code updates (and you should — I'm planning to maintain and update this class!), either follow the project on GitHub or drop me an e-mail at [email protected].
Interface:
/*
ZoomScrollView makes UIScrollView easier to use:
- ZoomScrollView is a drop-in replacement subclass of UIScrollView
- ZoomScrollView adds programmatic zooming
(see `setZoomScale:centeredAt:animated:`)
- ZoomScrollView allows you to get the current zoom scale
(see `zoomScale` property)
- ZoomScrollView handles double-tap zooming for you
(see `zoomInOnDoubleTap`, `zoomOutOnDoubleTap`)
- ZoomScrollView forwards touch events to its delegate, allowing to handle
custom gestures easily (triple-tap? two-finger scrolling?)
Drop-in replacement:
You can replace `[UIScrollView alloc]` with `[ZoomScrollView alloc]` or change
class in Interface Builder, and everything should continue to work. The only
catch is that you should not *read* the 'delegate' property; to get your delegate,
please use zoomScrollViewDelegate property instead. (You can set the delegate
via either of these properties, but reading 'delegate' does not work.)
Zoom scale:
Reading zoomScale property returns the scale of the last scaling operation.
If your viewForZoomingInScrollView can return different views over time,
please keep in mind that any view you return is instantly scaled to zoomScale.
Delegate:
The delegate accepted by ZoomScrollView is a regular UIScrollViewDelegate,
however additional methods from `NSObject(ZoomScrollViewDelegateMethods)` category
will be called on your delegate if defined.
Method `scrollViewDidEndZooming:withView:atScale:` is called after any 'bounce'
animations really finish. UIScrollView often calls it earlier, violating
the documented contract of UIScrollViewDelegate.
Instead of reading 'delegate' property (which currently returns the scroll
view itself), you should read 'zoomScrollViewDelegate' property which
correctly returns your delegate. Setting works with either of them (so you
can still set your delegate in the Interface Builder).
*/
@interface ZoomScrollView : UIScrollView {
@private
BOOL _zoomInOnDoubleTap;
BOOL _zoomOutOnDoubleTap;
BOOL _zoomingDidEnd;
BOOL _ignoreSubsequentTouches; // after one of delegate touch methods returns YES, subsequent touch events are not forwarded to UIScrollView
float _zoomScale;
float _realMinimumZoomScale, _realMaximumZoomScale; // as set by the user (UIScrollView's min/maxZoomScale == our min/maxZoomScale divided by _zoomScale)
id _realDelegate; // as set by the user (UIScrollView's delegate is set to self)
UIView *_realZoomView; // the view for zooming returned by the delegate
UIView *_zoomWrapperView; // the disposable wrapper view actually used for zooming
}
// if both are enabled, zoom-in takes precedence unless the view is at maximum zoom scale
@property(nonatomic, assign) BOOL zoomInOnDoubleTap;
@property(nonatomic, assign) BOOL zoomOutOnDoubleTap;
@property(nonatomic, assign) id<UIScrollViewDelegate> zoomScrollViewDelegate;
@end
@interface ZoomScrollView (Zooming)
@property(nonatomic, assign) float zoomScale; // from minimumZoomScale to maximumZoomScale
- (void)setZoomScale:(float)zoomScale animated:(BOOL)animated; // centerPoint == center of the scroll view
- (void)setZoomScale:(float)zoomScale centeredAt:(CGPoint)centerPoint animated:(BOOL)animated;
@end
@interface NSObject (ZoomScrollViewDelegateMethods)
// return YES to stop processing, NO to pass the event to UIScrollView (mnemonic: default is to pass, and default return value in Obj-C is NO)
- (BOOL)zoomScrollView:(ZoomScrollView *)zoomScrollView touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event;
- (BOOL)zoomScrollView:(ZoomScrollView *)zoomScrollView touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event;
- (BOOL)zoomScrollView:(ZoomScrollView *)zoomScrollView touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event;
- (BOOL)zoomScrollView:(ZoomScrollView *)zoomScrollView touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event;
@end
Implementation:
@interface ZoomScrollView (DelegateMethods) <UIScrollViewDelegate>
@end
@interface ZoomScrollView (ZoomingPrivate)
- (void)_setZoomScaleAndUpdateVirtualScales:(float)zoomScale; // set UIScrollView's minimumZoomScale/maximumZoomScale
- (BOOL)_handleDoubleTapWith:(UITouch *)touch;
- (UIView *)_createWrapperViewForZoomingInsteadOfView:(UIView *)view; // create a disposable wrapper view for zooming
- (void)_zoomDidEndBouncing;
- (void)_programmaticZoomAnimationDidStop:(NSString *)animationID finished:(NSNumber *)finished context:(UIView *)context;
- (void)_setTransformOn:(UIView *)view;
@end
@implementation ZoomScrollView
@synthesize zoomInOnDoubleTap=_zoomInOnDoubleTap, zoomOutOnDoubleTap=_zoomOutOnDoubleTap;
@synthesize zoomScrollViewDelegate=_realDelegate;
- (id)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
_zoomScale = 1.0f;
_realMinimumZoomScale = super.minimumZoomScale;
_realMaximumZoomScale = super.maximumZoomScale;
super.delegate = self;
}
return self;
}
- (id)initWithCoder:(NSCoder *)aDecoder {
if (self = [super initWithCoder:aDecoder]) {
_zoomScale = 1.0f;
_realMinimumZoomScale = super.minimumZoomScale;
_realMaximumZoomScale = super.maximumZoomScale;
super.delegate = self;
}
return self;
}
- (id<UIScrollViewDelegate>)realDelegate {
return _realDelegate;
}
- (void)setDelegate:(id<UIScrollViewDelegate>)delegate {
_realDelegate = delegate;
}
- (float)minimumZoomScale {
return _realMinimumZoomScale;
}
- (void)setMinimumZoomScale:(float)value {
_realMinimumZoomScale = value;
[self _setZoomScaleAndUpdateVirtualScales:_zoomScale];
}
- (float)maximumZoomScale {
return _realMaximumZoomScale;
}
- (void)setMaximumZoomScale:(float)value {
_realMaximumZoomScale = value;
[self _setZoomScaleAndUpdateVirtualScales:_zoomScale];
}
@end
@implementation ZoomScrollView (Zooming)
- (void)_setZoomScaleAndUpdateVirtualScales:(float)zoomScale {
_zoomScale = zoomScale;
// prevent accumulation of error, and prevent a common bug in the user's code (comparing floats with '==')
if (ABS(_zoomScale - _realMinimumZoomScale) < 1e-5)
_zoomScale = _realMinimumZoomScale;
else if (ABS(_zoomScale - _realMaximumZoomScale) < 1e-5)
_zoomScale = _realMaximumZoomScale;
super.minimumZoomScale = _realMinimumZoomScale / _zoomScale;
super.maximumZoomScale = _realMaximumZoomScale / _zoomScale;
}
- (void)_setTransformOn:(UIView *)view {
if (ABS(_zoomScale - 1.0f) < 1e-5)
view.transform = CGAffineTransformIdentity;
else
view.transform = CGAffineTransformMakeScale(_zoomScale, _zoomScale);
}
- (float)zoomScale {
return _zoomScale;
}
- (void)setZoomScale:(float)zoomScale {
[self setZoomScale:zoomScale animated:NO];
}
- (void)setZoomScale:(float)zoomScale animated:(BOOL)animated {
[self setZoomScale:zoomScale centeredAt:CGPointMake(self.frame.size.width / 2, self.frame.size.height / 2) animated:animated];
}
- (void)setZoomScale:(float)zoomScale centeredAt:(CGPoint)centerPoint animated:(BOOL)animated {
if (![_realDelegate respondsToSelector:@selector(viewForZoomingInScrollView:)]) {
NSLog(@"setZoomScale called on ZoomScrollView, however delegate does not implement viewForZoomingInScrollView");
return;
}
// viewForZoomingInScrollView may change contentOffset, and centerPoint is relative to the current one
CGPoint origin = self.contentOffset;
centerPoint = CGPointMake(centerPoint.x - origin.x, centerPoint.y - origin.y);
UIView *viewForZooming = [_realDelegate viewForZoomingInScrollView:self];
if (viewForZooming == nil)
return;
if (animated) {
[UIView beginAnimations:nil context:viewForZooming];
[UIView setAnimationDuration: 0.2];
[UIView setAnimationDelegate: self];
[UIView setAnimationDidStopSelector: @selector(_programmaticZoomAnimationDidStop:finished:context:)];
}
[self _setZoomScaleAndUpdateVirtualScales:zoomScale];
[self _setTransformOn:viewForZooming];
CGSize zoomViewSize = viewForZooming.frame.size;
CGSize scrollViewSize = self.frame.size;
viewForZooming.frame = CGRectMake(0, 0, zoomViewSize.width, zoomViewSize.height);
self.contentSize = zoomViewSize;
self.contentOffset = CGPointMake(MAX(MIN(zoomViewSize.width*centerPoint.x/scrollViewSize.width - scrollViewSize.width/2, zoomViewSize.width - scrollViewSize.width), 0),
MAX(MIN(zoomViewSize.height*centerPoint.y/scrollViewSize.height - scrollViewSize.height/2, zoomViewSize.height - scrollViewSize.height), 0));
if (animated) {
[UIView commitAnimations];
} else {
[self _programmaticZoomAnimationDidStop:nil finished:nil context:viewForZooming];
}
}
- (void)_programmaticZoomAnimationDidStop:(NSString *)animationID finished:(NSNumber *)finished context:(UIView *)context {
if ([_realDelegate respondsToSelector:@selector(scrollViewDidEndZooming:withView:atScale:)])
[_realDelegate scrollViewDidEndZooming:self withView:context atScale:_zoomScale];
}
- (BOOL)_handleDoubleTapWith:(UITouch *)touch {
if (!_zoomInOnDoubleTap && !_zoomOutOnDoubleTap)
return NO;
if (_zoomInOnDoubleTap && ABS(_zoomScale - _realMaximumZoomScale) > 1e-5)
[self setZoomScale:_realMaximumZoomScale centeredAt:[touch locationInView:self] animated:YES];
else if (_zoomOutOnDoubleTap && ABS(_zoomScale - _realMinimumZoomScale) > 1e-5)
[self setZoomScale:_realMinimumZoomScale animated:YES];
return YES;
}
// the heart of the zooming technique: zooming starts here
- (UIView *)_createWrapperViewForZoomingInsteadOfView:(UIView *)view {
if (_zoomWrapperView != nil) // not sure this is really possible
[self _zoomDidEndBouncing]; // ...but just in case cleanup the previous zoom op
_realZoomView = [view retain];
[view removeFromSuperview];
[self _setTransformOn:_realZoomView]; // should be already set, except if this is a different view
_realZoomView.frame = CGRectMake(0, 0, _realZoomView.frame.size.width, _realZoomView.frame.size.height);
_zoomWrapperView = [[UIView alloc] initWithFrame:view.frame];
[_zoomWrapperView addSubview:view];
[self addSubview:_zoomWrapperView];
return _zoomWrapperView;
}
// the heart of the zooming technique: zooming ends here
- (void)_zoomDidEndBouncing {
_zoomingDidEnd = NO;
[_realZoomView removeFromSuperview];
[self _setTransformOn:_realZoomView];
_realZoomView.frame = _zoomWrapperView.frame;
[self addSubview:_realZoomView];
[_zoomWrapperView release];
_zoomWrapperView = nil;
if ([_realDelegate respondsToSelector:@selector(scrollViewDidEndZooming:withView:atScale:)])
[_realDelegate scrollViewDidEndZooming:self withView:_realZoomView atScale:_zoomScale];
[_realZoomView release];
_realZoomView = nil;
}
@end
@implementation ZoomScrollView (DelegateMethods)
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
if ([_realDelegate respondsToSelector:@selector(scrollViewWillBeginDragging:)])
[_realDelegate scrollViewWillBeginDragging:self];
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
if ([_realDelegate respondsToSelector:@selector(scrollViewDidEndDragging:willDecelerate:)])
[_realDelegate scrollViewDidEndDragging:self willDecelerate:decelerate];
}
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView {
if ([_realDelegate respondsToSelector:@selector(scrollViewWillBeginDecelerating:)])
[_realDelegate scrollViewWillBeginDecelerating:self];
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
if ([_realDelegate respondsToSelector:@selector(scrollViewDidEndDecelerating:)])
[_realDelegate scrollViewDidEndDecelerating:self];
}
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
if ([_realDelegate respondsToSelector:@selector(scrollViewDidEndScrollingAnimation:)])
[_realDelegate scrollViewDidEndScrollingAnimation:self];
}
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView {
UIView *viewForZooming = nil;
if ([_realDelegate respondsToSelector:@selector(viewForZoomingInScrollView:)])
viewForZooming = [_realDelegate viewForZoomingInScrollView:self];
if (viewForZooming != nil)
viewForZooming = [self _createWrapperViewForZoomingInsteadOfView:viewForZooming];
return viewForZooming;
}
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(float)scale {
[self _setZoomScaleAndUpdateVirtualScales:_zoomScale * scale];
// often UIScrollView continues bouncing even after the call to this method, so we have to use delays
_zoomingDidEnd = YES; // signal scrollViewDidScroll to schedule _zoomDidEndBouncing call
[self performSelector:@selector(_zoomDidEndBouncing) withObject:nil afterDelay:0.1];
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (_zoomWrapperView != nil && _zoomingDidEnd) {
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(_zoomDidEndBouncing) object:nil];
[self performSelector:@selector(_zoomDidEndBouncing) withObject:nil afterDelay:0.1];
}
if ([_realDelegate respondsToSelector:@selector(scrollViewDidScroll:)])
[_realDelegate scrollViewDidScroll:self];
}
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView {
if ([_realDelegate respondsToSelector:@selector(scrollViewShouldScrollToTop:)])
return [_realDelegate scrollViewShouldScrollToTop:self];
else
return YES;
}
- (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView {
if ([_realDelegate respondsToSelector:@selector(scrollViewDidScrollToTop:)])
[_realDelegate scrollViewDidScrollToTop:self];
}
@end
@implementation ZoomScrollView (EventForwarding)
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
id delegate = self.delegate;
if ([delegate respondsToSelector:@selector(zoomScrollView:touchesBegan:withEvent:)])
_ignoreSubsequentTouches = [delegate zoomScrollView:self touchesBegan:touches withEvent:event];
if (_ignoreSubsequentTouches)
return;
if ([touches count] == 1 && [[touches anyObject] tapCount] == 2)
if ([self _handleDoubleTapWith:[touches anyObject]])
return;
[super touchesBegan:touches withEvent:event];
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
id delegate = self.delegate;
if ([delegate respondsToSelector:@selector(zoomScrollView:touchesMoved:withEvent:)])
if ([delegate zoomScrollView:self touchesMoved:touches withEvent:event]) {
_ignoreSubsequentTouches = YES;
[super touchesCancelled:touches withEvent:event];
}
if (_ignoreSubsequentTouches)
return;
[super touchesMoved:touches withEvent:event];
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
id delegate = self.delegate;
if ([delegate respondsToSelector:@selector(zoomScrollView:touchesEnded:withEvent:)])
if ([delegate zoomScrollView:self touchesEnded:touches withEvent:event]) {
_ignoreSubsequentTouches = YES;
[super touchesCancelled:touches withEvent:event];
}
if (_ignoreSubsequentTouches)
return;
[super touchesEnded:touches withEvent:event];
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
id delegate = self.delegate;
if ([delegate respondsToSelector:@selector(zoomScrollView:touchesCancelled:withEvent:)])
if ([delegate zoomScrollView:self touchesCancelled:touches withEvent:event])
_ignoreSubsequentTouches = YES;
[super touchesCancelled:touches withEvent:event];
}
@end
Solution 4:
I think I figured out what documentation Darron was referring to. In the document "iPhone OS Programming Guide" there's a section "Handling Multi-Touch Events". That contains listing 7-1:
- (void) touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event {
UIScrollView *scrollView = (UIScrollView*)[self superview];
UITouch *touch = [touches anyObject];
CGSize size;
CGPoint point;
if([touch tapCount] == 2) {
if(![_viewController _isZoomed]) {
point = [touch locationInView:self];
size = [self bounds].size;
point.x /= size.width;
point.y /= size.height;
[_viewController _setZoomed:YES];
size = [scrollView contentSize];
point.x *= size.width;
point.y *= size.height;
size = [scrollView bounds].size;
point.x -= size.width / 2;
point.y -= size.height / 2;
[scrollView setContentOffset:point animated:NO];
}
else
[_viewController _setZoomed:NO];
}
}
}