Executive Summary:
At times UIScrollView
makes an unwanted change to the value of contentOffset
, thus causing the app to display the wrong location in the document being viewed. The unwanted change happens in conjunction to an animated change to the scroll view's zoomScale
.
The Details:
I'm having trouble when zooming out with CATiledLayer
in a UIScrollView
. The CATiledLayer
holds a pdf, and when contentOffset
is within a certain range, when I zoom out, the contentOffset
is changed (that's the bug) before the zooming occurs. The contentOffset
seems to be changed in Apple's code.
To illustrate the problem, I modified Apple's sample app, ZoomingPDFViewer. The code is on github: https://github.com/DirkMaas/ZoomingPDFViewer-bug
A tap will cause zoomScale
to be changed to 0.5, using animateWithDuration
, thus zooming out. If the UIScrollView
's contentOffset.y
is less than about 2700 or greater than 5900, the zoomScale
animation works fine. If the tap happens when contentOffset.y
is between those two values, the contentOffset.y
will jump (not animated) to about 2700, and then the zoomScale
animation will occur, but scrolling will occur at the same time, so that when the animation is done, the contentOffset.y
is where it should be. But where does the jump come from?
For example, say the contentOffset.y
is 2000 when the screen is tapped: the zoomScale
animation works just fine; contentOffset.y
is not changed.
But if the contentOffset.y
is 4000 when the screen is tapped: the contentOffset.y
will jump, without animation, to about 2700, and then zooming and scrolling will begin from that point and occur at the same time. When the animation is done, it looks as if we zoomed straight back from 4000, so we end up in the right place, but the behavior is wrong.
A note on the UI:
- the text can be scrolled vertically in the normal way
- the text can be zoomed in and out by pinching in the normal way
- a single tap will cause the
zoomScale
to be set to 0.5; the change is animated
I've noticed that if zoomScale
is greater than 0.5, the jump is not so big. Also, if I use setZoomScale:animated:
instead of animateWithDuration
, the bug disappears, but I can't use it because I need to chain animations.
Here is a summary of what I did (the code in github includes these changes):
- Downloaded ZoomingPDFViewer from
http://developer.apple.com/library/ios/#samplecode/ZoomingPDFViewer/Introduction/Intro.html and opened it in XCode
- Changed Build Settings | Architectures | Base SDK to Latest iOS (iOS 4.3) changed Build Settings | GCC 4.2 - Language | Compile Sources As to Objective-C++
- removed TestPage.pdf from the project
- added "whoiam 5 24 cropped 3-2.pdf" to the project in its place
- added
PDFScrollView *scrollView;
to ZoomingPDFViewerViewController
class
- changed
loadView
in ZoomingPDFViewerViewController
to initialize scrollView
instead of sv
- added
viewDidLoad
, handleTapFrom:recognizer
and zoomOut
to ZoomingPDFViewerViewController
in PDFScrollview.m
- commented out
scrollViewDidEndZooming:withView:atScale
and scrollViewWillBeginZooming:withView:
because they do stuff in the image background that distracts from the issue at hand
Thanks so much for bearing with me, and any and all help!
One of the trickiest things to understand about zooming is that it always happens around a point called the Anchor Point. I think the best way to understand it is to imagine one coordinate system layered on top of another. Say A is your outer coordinate system, B is the inner (B will be the scrollview). When the offset of B is (0,0) and the scale is 1.0, then the point B(0,0) corresponds to A(0,0), and in general B(x,y) = A(x,y).
Further, if the offset of B is (xOff, yOff) then B(x,y) = A(x - xOff, y - yOff). Again, this is still assuming zoom scale is 1.0.
Now, let the offset be (0,0) again and imagine what happens when you try to zoom. There must be a point on the screen that doesn't move when you zoom, and every other point moves outward from that point. That is what the anchor point defines. If your anchor is (0,0) then the bottom left point will remain fixed while all other points move up and to the right. In this case the offset remains the same.
If your anchor point is (0.5, 0.5) (the anchor point is normalized, i.e. 0 to 1, so 0.5 is half way across), then the center point stays fixed while all other points move outward. This means the offset has to change to reflect that. If it's on an iPhone in portrait mode and you zoom to scale 2.0, the anchor point x value will move half the screen width, 320/2 = 160.
The actual position of the scroll views content view on screen is defined by BOTH the offset and the anchor point. So, if you simply change the layers anchor point underneath without making a corresponding change to the offset, you will see the view appear to jump to a different location, even though the offset is the same.
I am guessing that this is the underlying problem here. When you are animating the zoom, Core Animation must be picking a new anchor point so the zooming "looks" right. This will also change the offset so that the views actual visible region on screen doesn't jump. Try logging the location of the anchor point at various times throughout this process (it is defined on the underlying CALayer of any View which you access with a Views "layer" property).
Also, please see the documentation here for pretty pictures and likely a far better description of the situation than I've given here :)
Finally, here is a snippet of code I used in an app to change the anchor point of a layer without it moving on screen.
-(CGPoint)setNewAnchorPointWithoutMoving:(CGPoint)newAnchor {
CGPoint currentAnchor = CGPointMake(self.anchorPoint.x * self.contentSize.width,
self.anchorPoint.y * self.contentSize.height);
CGPoint offset = CGPointMake((1 - self.scale) * (currentAnchor.x - newAnchor.x),
(1 - self.scale) * (currentAnchor.y - newAnchor.y));
self.anchorPoint = CGPointMake(newAnchor.x / self.contentSize.width,
newAnchor.y / self.contentSize.height);
self.position = CGPointMake(self.position.x + offset.x, self.position.y + offset.y);
return offset;
}
I've been playing with the code you posted and I think I've found what's going on. When you do a block animation like in your code:
[UIView animateWithDuration:4.0
animations:^ {
self.scrollView.zoomScale = 0.5;
}
completion:nil];
The animation block actually gets called at the beginning of the animation. Core Animation internally handles the layers that make it look like it's actually moving. There is a list of Animatable Properties on the Dev Center, and zoomScale is not among them.
When the zoomScale changes, the scrollView automatically updates its contentOffset. So when the animation begins, the jump you are seeing is the contentOffset being adjusted for a new zoomScale. The layer is then animated to the proper zoom scale, but the target contentOffset (i.e. what the contentOffset should be at the end of the zooming) is already set at the beginning of the zooming.
That's also why the zooming looks like it is centered around a point off the screen. You are either going to have to use setZoomScale:animated: or else maybe animate the layer and the offset yourself...
You should use scrollViewDidEndZooming:withView:atScale to notify you of when the zoom is complete. Otherwise I believe you will have to remove the use of the property zoomScale of the UIScrollView and instead implement zooming completely manually, e.g. http://jonathanwatmough.com/2008/12/implementing-tap-to-zoom-in-uiscrollview-on-an-iphone/.
I had many bugs with zooming when my viewForZoomingInScrollView function was returning the scrollview directly.
Many of those strange behaviors disappeared after I set a view inside the scrollView that was containing everything and returning it in viewForZoomingInScrollView. Maybe this should solve your problem too :
-(void) someIntializationFunction {
[myScrollView addSubView:self.globalContainer];
// Now had everything in the container and not in the scrollView directly
....
}
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView; {
return self.globalContainer;
}
Another thing, I've seen zoomToRect: function much more reliable than changing the zoomScale of the scrollView. This could help too, even if you'll have more calculation to make...
I think you have to manipulate the contentInset and/or contentOffset yourself, on each scroll event. Manipulating the anchor points will not help. Look at this question and my answer.