Interactive ViewController transition triggered by pinch and pan gesture recognisers simultaneously
Asked Answered
H

2

8

enter image description here

I have two viewControllers:

ViewController1

A complex stack of sub viewcontrollers with somewhere in the middle an imageView

ViewController2

A scrollView with an imageView embedded in it

What I'm trying to achieve is a transition between the two viewControllers which gets triggered by pinching the imageView from viewController 1 causing it to zoom in and switch over to viewController 2. When the transition has ended, the imageView should be zoomed in as far as it's been zoomed during the pinch gesture triggered transition.

At the same time I want to support panning the image while performing the zoom transition so that just like with the zoom, the image in the end state will be transformed to the place it's been panned to.

So far I've tried the Hero transitions pod and a custom viewController transitions I wrote myself. The problem with the hero transitions is that the image doesn't properly get snapped to the end state in the second viewController. The problem I had with the custom viewController transition is that I couldn't get both zooming and panning to work at the same time.

Does anyone have an idea of how to implement this in Swift? Help is much appreciated.

Homologate answered 26/2, 2018 at 13:56 Comment(6)
Such a library might be helpful...Engorge
Thanks! But that one also doesn’t seem to be controlled by both pinch and pan at the same time..Homologate
Correct me if I'm wrong, you want an interactive transition from vc1 to vc2 to start from either pinch or pan and transition ends always on vc2 keeping the image view positioned exactly where the gestures left it? Is vc2 pushed to a navigation stack, presented modally or simply presented?Electroform
Pinch and pan at the same time, so zoom while horizontal and vertical shifting might take place. The result is a modally presented VC2Homologate
Can you post your custom implementation here, so maybe I can help to get zoom and pan work together?Marsland
@Homologate I've implemented pinch and pan gestures and presentation of VC2 at the end of either gesture. You can head to the complete example at the end and run it on a test project.Electroform
E
4

The question can be divided in to two:

  1. How to implement pinch zoom and dragging using pan gesture on an imageView
  2. How to present a view controller with one of its subviews (imageView in vc2) positioned same as a subview (imageView in vc1) in the presenting view controller

Pinch gesture zoom: Pinch zooming is easier to implement using UIScrollView as it supports it out of the box with out a need to add the gesture recogniser. Create a scrollView and add the view you'd like to zoom with pinch as its subview (scrollView.addSubview(imageView)). Don't forget to add the scrollView itself as well (view.addSubview(scrollView)).

Configure the scrollView's min and max zoom scales: scrollView.minimumZoomScale, scrollView.maximumZoomScale. Set a delegate for scrollView.delegate and implement UIScrollViewDelegate:

func viewForZooming(in scrollView: UIScrollView) -> UIView?

Which should return your imageView in this case and,

Also conform to UIGestureRecognizerDelegate and implement:

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool

Which should return true. This is the key that allows us have pan gesture recogniser work with the internal pinch gesture recogniser.

Pan gesture dragging: Simply create a pan gesture recogniser with a target and add it to your scroll view scrollView.addGestureRecognizer(pan).

Handling gestures: Pinch zoom is working nicely by this stage except you'd like to present the second view controller when pinching ends. Implement one more UIScrollViewDelegate method to be notified when zooming ends:

func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat)

And call your method that presents the detail view controller presentDetail(), we'll implement it in a bit.

Next step is to handle the pan gesture, I'll let the code explain itself:

// NOTE: Do NOT set from anywhere else than pan handler.
private var initialTouchPositionY: CGFloat = 0
private var initialTouchPositionX: CGFloat = 0

@objc func panned(_ pan: UIPanGestureRecognizer) {

    let y = pan.location(in: scrollView).y
    let x = pan.location(in: scrollView).x

    switch pan.state {
    case .began:
        initialTouchPositionY = pan.location(in: imageView).y
        initialTouchPositionX = pan.location(in: imageView).x
    case .changed:
        let offsetY = y - initialTouchPositionY
        let offsetX = x - initialTouchPositionX
        imageView.frame.origin = CGPoint(x: offsetX, y: offsetY)
    case .ended:
        presentDetail()
    default: break
    }
}

The implementation moves imageView around following the pan location and calls presentDetail() when gesture ends.

Before we implement presentDetail(), head to the detail view controller and add properties to hold imageViewFrame and the image itself. Now in vc1, we implement presentDetail() as such:

private func presentDetail() {
    let frame = view.convert(imageView.frame, from: scrollView)
    let detail = DetailViewController()
    detail.imageViewFrame = frame
    detail.image = imageView.image

    // Note that we do not need the animation. 
    present(detail, animated: false, completion: nil)
}

In your DetailViewController, make sure to set the imageViewFrame and the image in e.g. viewDidLoad and you'll be set.

Complete working example:

class ViewController: UIViewController, UIScrollViewDelegate, UIGestureRecognizerDelegate {

    let imageView: UIImageView = UIImageView()
    let scrollView: UIScrollView = UIScrollView()

    lazy var pan: UIPanGestureRecognizer = {
        return UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        imageView.image = // set your image

        scrollView.delegate = self
        scrollView.minimumZoomScale = 1.0
        scrollView.maximumZoomScale = 10.0

        scrollView.addSubview(imageView)
        view.addSubview(scrollView)

        scrollView.frame = view.frame

        let w = view.bounds.width - 30 // padding of 15 on each side
        imageView.frame = CGRect(x: 0, y: 0, width: w, height: w)
        imageView.center = scrollView.center

        scrollView.addGestureRecognizer(pan)
    }

    // NOTE: Do NOT set from anywhere else than pan handler.
    private var initialTouchPositionY: CGFloat = 0
    private var initialTouchPositionX: CGFloat = 0

    @objc func panned(_ pan: UIPanGestureRecognizer) {

        let y = pan.location(in: scrollView).y
        let x = pan.location(in: scrollView).x

        switch pan.state {
        case .began:
            initialTouchPositionY = pan.location(in: imageView).y
            initialTouchPositionX = pan.location(in: imageView).x
        case .changed:
            let offsetY = y - initialTouchPositionY
            let offsetX = x - initialTouchPositionX
            imageView.frame.origin = CGPoint(x: offsetX, y: offsetY)
        case .ended:
            presentDetail()
        default: break
        }
    }

    // MARK: UIScrollViewDelegate

    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }

    func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
        presentDetail()
    }

    // MARK: UIGestureRecognizerDelegate

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }

    // MARK: Private

    private func presentDetail() {
        let frame = view.convert(imageView.frame, from: scrollView)
        let detail = DetailViewController()
        detail.imageViewFrame = frame
        detail.image = imageView.image
        present(detail, animated: false, completion: nil)
    }
}

class DetailViewController: UIViewController {

    let imageView: UIImageView = UIImageView()
    var imageViewFrame: CGRect!
    var image: UIImage?

    override func viewDidLoad() {
        super.viewDidLoad()
        imageView.frame = imageViewFrame
        imageView.image = image

        view.addSubview(imageView)
        view.addSubview(backButton)
    }

    lazy var backButton: UIButton = {
        let button: UIButton = UIButton(frame: CGRect(x: 10, y: 30, width: 60, height: 30))
        button.addTarget(self, action: #selector(back(_:)), for: .touchUpInside)
        button.setTitle("back", for: .normal)
        return button
    }()

    @objc func back(_ sender: UIButton) {
        dismiss(animated: false, completion: nil)
    }

}
Electroform answered 7/3, 2018 at 22:26 Comment(3)
Thanks for the elaborate answer, this is much appreciated. I think this looks most like the strategy I will have to take however do do have some hurdles to overcome still, the biggest one being that vc1 is among other things part of a pageVieuwcontroller stack which in already has a scrollView backing it. Since this is definitely helpful and the bounty ends soon, I’ll reward you with it.Homologate
@Homologate It shouldn't matter how many scroll views you have stacked. User interaction always first goes to the view at the top in the stack, It only passes through iff that view has isUserInteractionEnabled set to false or overrides and propagates touch delegates (touchesBegan...) by calling super which will be any view down the stack up until window. Anyway, if you need help with that also, you can ask it with some code and detail of vc1 and send me link to the question here.Electroform
@Electroform How do you handle the zoom if the scroll view where the image view is contained in the first vc is smaller than screen? For instance imagine that you have a cell inside a collection view; this cell has a scrollview that matches its bounds and a image view. If you start zooming you won't be able to pass the bounds of the cell, but I'd like that while pinching you can make it to full screen. If you want to see exactly what I mean you can check this behaviour in Pinterest, in the detail of a post. Thanks for time.Grace
T
3

seems like UIView.animate(withDuration: animations: completion:) should help you; for example, in animations block you can set new image frame, and in completion: - present second view controller (without animation);

Turbo answered 6/3, 2018 at 23:19 Comment(1)
I don’t see this working as the animations closure forces you to define an end state where this should be defined by the pinch and pan gesture.Homologate

© 2022 - 2024 — McMap. All rights reserved.