top - scrollview onscroll

Unwanted scrolling when animating zoomScale in UIScrollView (5)

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;

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...

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:

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

CAKeyFrameAnimation of transform property on CALayer not working as expected

You are concatenating translation matrix with scaled matrix. The final value of displacement offset will be Offset(X, Y) = (scaleX * Tx, scaleY * Ty).

If you want to move the UIView with (Tx, Ty) offset, than concatenate the translate and scale matrices as below:

CATransform3D scale = CATransform3DMakeScale(zoomScale, zoomScale, 1);
CATransform3D translate = CATransform3DMakeTranslation(-scrollViewContentOffset.x, 
                                                       -scrollViewContentOffset.y, 0);
CATransform3D concat = CATransform3DConcat(translate, scale);

Try this and let me know if it works.

zoomScale, contentOffset and Retina


When I replaced in the initialization

 self.scrollView.contentSize = (CGSize) {CGFLOAT_MAX, CGFLOAT_MAX} ;


self.scrollView.contentSize = (CGSize) {1e9, 1e9} ;

Then the zoomScale and contentOffset become consistant across retina and non retina device.

My guess is that when Apple introduced the retina devices, they also (inadvertently?) reduced the span of the floating point coordinate space from



[undefined, CGFLOAT_MAX/retina-device-scale]