8
votes

I am struggling a bit trying to figure out if it is possible to create a single combined gesture recognizer that combines UIPinchGestureRecognizer with UIPanGestureRecognizer.

I am using pan for view translation and pinch for view scaling. I am doing incremental matrix concatenation to derive a resultant final transformation matrix that is applied to the view. This matrix has both scale and translation. Using separate gesture recognizers leads to a jittery movement/scaling. Not what I want. Thus, I want to handle concatenation of scale and translation once within a single gesture. Can someone please shed some light on how to do this?

3
Doug, I've updated my answer with complete source code and a sample project.Paul Solt

3 Answers

19
votes

6/14/14: Updated Sample Code for iOS 7+ with ARC.

The UIGestureRecognizers can work together and you just need to make sure you don't trash the current view's transform matrix. Use the CGAffineTransformScale method and related methods that take a transform as input, rather than creating it from scratch (unless you maintain the current rotation, scale, or translation yourself.

Download Xcode Project

Note: iOS 7 behaves weird with UIView's in IB that have Pan/Pinch/Rotate gestures applied. iOS 8 fixes it, but my workaround is to add all views in code like this code example.

Demo Video

Demo UIPinchGesture Video

  1. Add them to a view and conform to the UIGestureRecognizerDelegate protocol

    @interface ViewController () <UIGestureRecognizerDelegate>
    
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad
    {
        [super viewDidLoad];
    
        UIView *blueView = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 150, 150)];
        blueView.backgroundColor = [UIColor blueColor];
        [self.view addSubview:blueView];
        [self addMovementGesturesToView:blueView];
    
        // UIImageView's and UILabel's don't have userInteractionEnabled by default!
        UIImageView *imageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"BombDodge.png"]]; // Any image in Xcode project
        imageView.center = CGPointMake(100, 250);
        [imageView sizeToFit];
        [self.view addSubview:imageView];
        [self addMovementGesturesToView:imageView];
    
        // Note: Changing the font size would be crisper than zooming a font!
        UILabel *label = [[UILabel alloc] init];
        label.text = @"Hello Gestures!";
        label.font = [UIFont systemFontOfSize:30];
        label.textColor = [UIColor blackColor];
        [label sizeToFit];
        label.center = CGPointMake(100, 400);
        [self.view addSubview:label];
        [self addMovementGesturesToView:label];
    }
    
    - (void)addMovementGesturesToView:(UIView *)view {
        view.userInteractionEnabled = YES;  // Enable user interaction
    
        UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePanGesture:)];
        panGesture.delegate = self;
        [view addGestureRecognizer:panGesture];
    
        UIPinchGestureRecognizer *pinchGesture = [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(handlePinchGesture:)];
        pinchGesture.delegate = self;
        [view addGestureRecognizer:pinchGesture];
    }
    
  2. Implement gesture methods

    - (void)handlePanGesture:(UIPanGestureRecognizer *)panGesture {
        CGPoint translation = [panGesture translationInView:panGesture.view.superview];
    
        if (UIGestureRecognizerStateBegan == panGesture.state ||UIGestureRecognizerStateChanged == panGesture.state) {
            panGesture.view.center = CGPointMake(panGesture.view.center.x + translation.x,
                                                 panGesture.view.center.y + translation.y);
            // Reset translation, so we can get translation delta's (i.e. change in translation)
            [panGesture setTranslation:CGPointZero inView:self.view];
        }
        // Don't need any logic for ended/failed/canceled states
    }
    
    - (void)handlePinchGesture:(UIPinchGestureRecognizer *)pinchGesture {
    
        if (UIGestureRecognizerStateBegan == pinchGesture.state ||
            UIGestureRecognizerStateChanged == pinchGesture.state) {
    
            // Use the x or y scale, they should be the same for typical zooming (non-skewing)
            float currentScale = [[pinchGesture.view.layer valueForKeyPath:@"transform.scale.x"] floatValue];
    
            // Variables to adjust the max/min values of zoom
            float minScale = 1.0;
            float maxScale = 2.0;
            float zoomSpeed = .5;
    
            float deltaScale = pinchGesture.scale;
    
            // You need to translate the zoom to 0 (origin) so that you
            // can multiply a speed factor and then translate back to "zoomSpace" around 1
            deltaScale = ((deltaScale - 1) * zoomSpeed) + 1;
    
            // Limit to min/max size (i.e maxScale = 2, current scale = 2, 2/2 = 1.0)
            //  A deltaScale is ~0.99 for decreasing or ~1.01 for increasing
            //  A deltaScale of 1.0 will maintain the zoom size
            deltaScale = MIN(deltaScale, maxScale / currentScale);
            deltaScale = MAX(deltaScale, minScale / currentScale);
    
            CGAffineTransform zoomTransform = CGAffineTransformScale(pinchGesture.view.transform, deltaScale, deltaScale);
            pinchGesture.view.transform = zoomTransform;
    
            // Reset to 1 for scale delta's
            //  Note: not 0, or we won't see a size: 0 * width = 0
            pinchGesture.scale = 1;
        }
    }
    
    - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
        return YES; // Works for most use cases of pinch + zoom + pan
    }
    

Resources

2
votes

If anyone is interested in a Swift implementation of this using Metal to do the rendering, I have a project available here.

0
votes

Swift

Many thanks a lot to Paul!!! Here is his Swift version:

import UIKit

class ViewController: UIViewController, UIGestureRecognizerDelegate {

    var editorView: EditorView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let blueView = UIView(frame: .init(x: 100, y: 100, width: 300, height: 300))
        view.addSubview(blueView)
        blueView.backgroundColor = .blue
        addMovementGesturesToView(blueView)
    }

    func addMovementGesturesToView(_ view: UIView) {
        view.isUserInteractionEnabled = true

        let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
        panGesture.delegate = self
        view.addGestureRecognizer(panGesture)

        let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
        pinchGesture.delegate = self
        view.addGestureRecognizer(pinchGesture)
    }

    @objc private func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
        guard let panView = panGesture.view else { return }

        let translation = panGesture.translation(in: panView.superview)

        if panGesture.state == .began || panGesture.state == .changed {
            panGesture.view?.center = CGPoint(x: panView.center.x + translation.x, y: panView.center.y + translation.y)

            // Reset translation, so we can get translation delta's (i.e. change in translation)
            panGesture.setTranslation(.zero, in: self.view)
        }
        // Don't need any logic for ended/failed/canceled states
    }

    @objc private func handlePinchGesture(_ pinchGesture: UIPinchGestureRecognizer) {
        guard let pinchView = pinchGesture.view else { return }

        if pinchGesture.state == .began || pinchGesture.state == .changed {
            let currentScale = scale(for: pinchView.transform)

            // Variables to adjust the max/min values of zoom
            let minScale: CGFloat = 0.2
            let maxScale: CGFloat = 3
            let zoomSpeed: CGFloat = 0.8

            var deltaScale = pinchGesture.scale

            // You need to translate the zoom to 0 (origin) so that you
            // can multiply a speed factor and then translate back to "zoomSpace" around 1
            deltaScale = ((deltaScale - 1) * zoomSpeed) + 1

            // Limit to min/max size (i.e maxScale = 2, current scale = 2, 2/2 = 1.0)
            //  A deltaScale is ~0.99 for decreasing or ~1.01 for increasing
            //  A deltaScale of 1.0 will maintain the zoom size
            deltaScale = min(deltaScale, maxScale / currentScale)
            deltaScale = max(deltaScale, minScale / currentScale)

            let zoomTransform = pinchView.transform.scaledBy(x: deltaScale, y: deltaScale)
            pinchView.transform = zoomTransform

            // Reset to 1 for scale delta's
            //  Note: not 0, or we won't see a size: 0 * width = 0
            pinchGesture.scale = 1
        }
    }

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

    private func scale(for transform: CGAffineTransform) -> CGFloat {
        return sqrt(CGFloat(transform.a * transform.a + transform.c * transform.c))
    }
}

Demo (on Simulator):

enter image description here