Search code examples
iosswiftuiscrollviewzoomingcaanimation

How to track a zooming UIScrollView?


I have a zooming UIScrollView, and a non-zooming overlay view on which I animate markers. These markers need to track the location of some of the content of the UIScrollView (similar to the way a dropped pin needs to track a spot on the map as you pan and zoom).

I do so by triggering an update of the overlay view in response to the UIScrollView's layoutSubviews. This works, and the overlay tracks perfectly when zooming and panning.

But when the pinch gesture ends the UIScrollView automatically performs a final animation, and the overlay view is out of sync for the duration of this animation.

I made a simplified project to isolate this problem. The UIScrollView contains an orange square, and the overlay view displays a 2-pixel red outline around the frame of this orange square. As you can see below, the red outline always moves to where it should be, except for a short period of time after touch ends, when it visibly jumps ahead to the final position of the orange square.

red line around a zooming rectangle

The full Xcode project for this test is available here: https://github.com/Clafou/ScrollviewZoomTrackTest but all the code is in the two files shown below:

TrackedScrollView.swift:

class TrackedScrollView: UIScrollView {

    @IBOutlet var overlaysView: UIView?

    let square: UIView

    required init(coder aDecoder: NSCoder) {
        square = UIView(frame: CGRect(x: 50, y: 300, width: 300, height: 300))
        square.backgroundColor = UIColor.orangeColor()

        super.init(coder: aDecoder)

        self.addSubview(square)
        self.maximumZoomScale = 1
        self.minimumZoomScale = 0.5
        self.contentSize = CGSize(width: 500, height: 900)
        self.delegate = self
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        overlaysView?.setNeedsLayout()
    }
}

extension TrackedScrollView: UIScrollViewDelegate {
    func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
        return square
    }
}

OverlaysView.swift:

class OverlaysView: UIView {

    @IBOutlet var trackedScrollView: TrackedScrollView?

    let outline: CALayer

    required init(coder aDecoder: NSCoder) {
        outline = CALayer()
        outline.borderColor = UIColor.redColor().CGColor
        outline.borderWidth = 2
        super.init(coder: aDecoder)
        self.layer.addSublayer(outline)
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        if let trackedScrollView = self.trackedScrollView {
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            let frame = trackedScrollView.convertRect(trackedScrollView.square.frame, toView: self)
            outline.frame = CGRectIntegral(CGRectInset(frame, -3, -3))
            CATransaction.commit()
        }
    }
}

Among the things I tried was using a CADisplayLink and presentationLayer and this allowed me to animate the overlay, but the coordinates that I obtained from presentationLayer lagged slightly behind the actual UIScrollView, so this still didn't look right. I think the right approach would be to tie my overlay update to the system-created UIScrollView animation, but I haven't had success hacking this so far.

How can I update this code to always track the UIScrollView's zooming content?


Solution

  • UIScrollView sends scrollViewDidZoom: to its delegate in its animation block, if it's “bouncing” back to its minimum or maximum zoom when the pinch ends. Update the frames of your overlays in scrollViewDidZoom: if zoomBouncing is true. If you're using Auto Layout call layoutIfNeeded.

    scrollViewDidZoom: is only called once during the zoom bounce animation but adjusting your frames or calling layoutIfNeeded will ensure these changes are animated along with the zoom bounce, thus keeping them perfectly in sync.

    Demo:

    demo of correct animation

    Fixed sample project: https://github.com/mayoff/ScrollviewZoomTrackTest