Pinch, Pan, and Rotate Text Simultaneously like Snapchat [SWIFT 3]
Asked Answered
C

3

13

I'm trying to make a TextView that you can move around like snapchat does. I have made something similar to it, although when you try and scale while rotates, it tends to stretch horizontally infinitely and dragging can be a little buggy at times.

I have this:

func panGesture(pan: UIPanGestureRecognizer) {
    print("Being Dragged")
    if pan.state == .began {
        textViewOrigin = pan.location(in: textView)
    }else {
        let location = pan.location(in: view) // get pan location
        textView.frame.origin = CGPoint(x: location.x - textViewOrigin.x, y: location.y - textViewOrigin.y)
    }
}
func scaleGesture(_ gesture: UIPinchGestureRecognizer){
    print("Being Scaled")
    switch gesture.state{
    case.began:
        identity = textView.transform
    case.changed,.ended:
        textView.transform = identity.scaledBy(x: gesture.scale, y: gesture.scale)
    default:
        break
    }
}
func rotationGesture(sender: UIRotationGestureRecognizer){
    print("Being Rotated")
    textView.transform = textView.transform.rotated(by: sender.rotation)
    sender.rotation = 0
}

and I am trying to accomplish this: enter image description here

If someone could help alter or re-write my code that would be great, thanks in advance!

Conchaconchie answered 30/7, 2017 at 17:23 Comment(0)
D
39

By default, after one gesture recognizer on a view starts handling the gesture, other recognizers are ignored. In Swift, this behaviour can be controlled by overriding the method,

gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)^1,

to return true. The default implementation returns false.

To override the function just add your implementation, returning true, to your ViewController source code file. Here is some sample Swift code:

class ViewController: UIViewController,UIGestureRecognizerDelegate {

    @IBOutlet var textField: UITextField!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        //add pan gesture
        let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
        gestureRecognizer.delegate = self
        textField.addGestureRecognizer(gestureRecognizer)

        //Enable multiple touch and user interaction for textfield
        textField.isUserInteractionEnabled = true
        textField.isMultipleTouchEnabled = true

        //add pinch gesture
        let pinchGesture = UIPinchGestureRecognizer(target: self, action:#selector(pinchRecognized(pinch:)))
        pinchGesture.delegate = self
        textField.addGestureRecognizer(pinchGesture)

        //add rotate gesture.
        let rotate = UIRotationGestureRecognizer.init(target: self, action: #selector(handleRotate(recognizer:)))
        rotate.delegate = self
        textField.addGestureRecognizer(rotate)


    }

    func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
        if gestureRecognizer.state == .began || gestureRecognizer.state == .changed {

            let translation = gestureRecognizer.translation(in: self.view)
            // note: 'view' is optional and need to be unwrapped
            gestureRecognizer.view!.center = CGPoint(x: gestureRecognizer.view!.center.x + translation.x, y: gestureRecognizer.view!.center.y + translation.y)
            gestureRecognizer.setTranslation(CGPoint.zero, in: self.view)
        }

    }

    func pinchRecognized(pinch: UIPinchGestureRecognizer) {

        if let view = pinch.view {
            view.transform = view.transform.scaledBy(x: pinch.scale, y: pinch.scale)
            pinch.scale = 1
        }
    }

    func handleRotate(recognizer : UIRotationGestureRecognizer) {
        if let view = recognizer.view {
            view.transform = view.transform.rotated(by: recognizer.rotation)
            recognizer.rotation = 0
        }
    }

    //MARK:- UIGestureRecognizerDelegate Methods
    func gestureRecognizer(_: UIGestureRecognizer,
                           shouldRecognizeSimultaneouslyWith shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
        return true
    }
}

Here's the crucial override in Objective-C^2:

-(BOOL)gestureRecognizer:(UIGestureRecognizer*)aR1 shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)aR2
{
    return YES;
}
Deathbed answered 2/8, 2017 at 8:5 Comment(4)
If you're using this in swift 4 you'll need to add @objc before each handle function. declaration. It used to be implicitly inferred before swift 4.Unarmed
If you wanted to capture the final coordinates/size/rotation/etc so you could place it accordingly on a post or whatever, similar to Snapchat, how would you do that?Nippers
for me textfield is not rotating but pan gesture is workingProverbs
@Nikhlesh Bagdiya can you please provide objective-c code?Avera
O
5

I write a class for easy to use.

Basic usage:

// define 
var snapGesture: SnapGesture?

// add gesture
self.snapGesture = SnapGesture(view: self.testView!)

// remove gesture
self.snapGesture = nil

Advanced usage, for scenario that the view receiving gesture is background view:

// add gesture
self.snapGesture = SnapGesture(transformView: self.testView!, gestureView: self.view)

// remove gesture
self.snapGesture = nil

class:

import UIKit

/*
 usage:

    add gesture:
        yourObjToStoreMe.snapGesture = SnapGesture(view: your_view)
    remove gesture:
        yourObjToStoreMe.snapGesture = nil
    disable gesture:
        yourObjToStoreMe.snapGesture.isGestureEnabled = false
    advanced usage:
        view to receive gesture(usually superview) is different from view to be transformed,
        thus you can zoom the view even if it is too small to be touched.
        yourObjToStoreMe.snapGesture = SnapGesture(transformView: your_view_to_transform, gestureView: your_view_to_recieve_gesture)

 */

class SnapGesture: NSObject, UIGestureRecognizerDelegate {

    // MARK: - init and deinit
    convenience init(view: UIView) {
        self.init(transformView: view, gestureView: view)
    }
    init(transformView: UIView, gestureView: UIView) {
        super.init()

        self.addGestures(v: gestureView)
        self.weakTransformView = transformView
    }
    deinit {
        self.cleanGesture()
    }

    // MARK: - private method
    private weak var weakGestureView: UIView?
    private weak var weakTransformView: UIView?

    private var panGesture: UIPanGestureRecognizer?
    private var pinchGesture: UIPinchGestureRecognizer?
    private var rotationGesture: UIRotationGestureRecognizer?

    private func addGestures(v: UIView) {

        panGesture = UIPanGestureRecognizer(target: self, action: #selector(panProcess(_:)))
        v.isUserInteractionEnabled = true
        panGesture?.delegate = self     // for simultaneous recog
        v.addGestureRecognizer(panGesture!)

        pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinchProcess(_:)))
        //view.isUserInteractionEnabled = true
        pinchGesture?.delegate = self   // for simultaneous recog
        v.addGestureRecognizer(pinchGesture!)

        rotationGesture = UIRotationGestureRecognizer(target: self, action: #selector(rotationProcess(_:)))
        rotationGesture?.delegate = self
        v.addGestureRecognizer(rotationGesture!)

        self.weakGestureView = v
    }

    private func cleanGesture() {
        if let view = self.weakGestureView {
            //for recognizer in view.gestureRecognizers ?? [] {
            //    view.removeGestureRecognizer(recognizer)
            //}
            if panGesture != nil {
                view.removeGestureRecognizer(panGesture!)
                panGesture = nil
            }
            if pinchGesture != nil {
                view.removeGestureRecognizer(pinchGesture!)
                pinchGesture = nil
            }
            if rotationGesture != nil {
                view.removeGestureRecognizer(rotationGesture!)
                rotationGesture = nil
            }
        }
        self.weakGestureView = nil
        self.weakTransformView = nil
    }




    // MARK: - API

    private func setView(view:UIView?) {
        self.setTransformView(view, gestgureView: view)
    }

    private func setTransformView(_ transformView: UIView?, gestgureView:UIView?) {
        self.cleanGesture()

        if let v = gestgureView  {
            self.addGestures(v: v)
        }
        self.weakTransformView = transformView
    }

    open func resetViewPosition() {
        UIView.animate(withDuration: 0.4) {
            self.weakTransformView?.transform = CGAffineTransform.identity
        }
    }

    open var isGestureEnabled = true

    // MARK: - gesture handle

    // location will jump when finger number change
    private var initPanFingerNumber:Int = 1
    private var isPanFingerNumberChangedInThisSession = false
    private var lastPanPoint:CGPoint = CGPoint(x: 0, y: 0)
    @objc func panProcess(_ recognizer:UIPanGestureRecognizer) {
        if isGestureEnabled {
            //guard let view = recognizer.view else { return }
            guard let view = self.weakTransformView else { return }

            // init
            if recognizer.state == .began {
                lastPanPoint = recognizer.location(in: view)
                initPanFingerNumber = recognizer.numberOfTouches
                isPanFingerNumberChangedInThisSession = false
            }

            // judge valid
            if recognizer.numberOfTouches != initPanFingerNumber {
                isPanFingerNumberChangedInThisSession = true
            }
            if isPanFingerNumberChangedInThisSession {
                return
            }

            // perform change
            let point = recognizer.location(in: view)
            view.transform = view.transform.translatedBy(x: point.x - lastPanPoint.x, y: point.y - lastPanPoint.y)
            lastPanPoint = recognizer.location(in: view)
        }
    }



    private var lastScale:CGFloat = 1.0
    private var lastPinchPoint:CGPoint = CGPoint(x: 0, y: 0)
    @objc func pinchProcess(_ recognizer:UIPinchGestureRecognizer) {
        if isGestureEnabled {
            guard let view = self.weakTransformView else { return }

            // init
            if recognizer.state == .began {
                lastScale = 1.0;
                lastPinchPoint = recognizer.location(in: view)
            }

            // judge valid
            if recognizer.numberOfTouches < 2 {
                lastPinchPoint = recognizer.location(in: view)
                return
            }

            // Scale
            let scale = 1.0 - (lastScale - recognizer.scale);
            view.transform = view.transform.scaledBy(x: scale, y: scale)
            lastScale = recognizer.scale;

            // Translate
            let point = recognizer.location(in: view)
            view.transform = view.transform.translatedBy(x: point.x - lastPinchPoint.x, y: point.y - lastPinchPoint.y)
            lastPinchPoint = recognizer.location(in: view)
        }
    }


    @objc func rotationProcess(_ recognizer: UIRotationGestureRecognizer) {
        if isGestureEnabled {
            guard let view = self.weakTransformView else { return }

            view.transform = view.transform.rotated(by: recognizer.rotation)
            recognizer.rotation = 0
        }
    }


    //MARK:- UIGestureRecognizerDelegate Methods
    func gestureRecognizer(_: UIGestureRecognizer,
                           shouldRecognizeSimultaneouslyWith shouldRecognizeSimultaneouslyWithGestureRecognizer:UIGestureRecognizer) -> Bool {
        return true
    }

}

Now, you reach this point, please continue. as you know, the gesture in superView may eat event of the subView, and in snapchat example, the gesture will eat event of the toolbar, we need prevent any gesture recognition from superview if we are touching the toolbar.

The idea is to add a pseduo customized gesture to the toolbar, so any gesture will be prevented to superview, and this pseduo gesture do nothing but deliver the gesture or event to subviews or its own view.

Here, I also write a class for easy to use.

usage:

   toolbarView.addGestureRecognizer(SnapBlockGestureRecognizer)

implementation:

import UIKit

class SnapBlockGestureRecognizer: UIGestureRecognizer {

    init() {
        //self.init(target: self, action: #selector(__dummyAction))
        super.init(target: nil, action: nil)

        self.addTarget(self, action: #selector(__dummyAction))
        self.cancelsTouchesInView = false
    }

    override init(target: Any?, action: Selector?) {
        super.init(target: target, action: action)

        self.cancelsTouchesInView = false
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        if self.state == .possible {
            self.state = .began
        }
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        self.state = .recognized
    }

    override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool {
        return self.isGestureRecognizerAllowed(gr:preventingGestureRecognizer)
    }


    override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
        return !(self.isGestureRecognizerAllowed(gr: preventedGestureRecognizer))
    }

    override func shouldBeRequiredToFail(by otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return !(self.isGestureRecognizerAllowed(gr: otherGestureRecognizer))
    }

    override func shouldRequireFailure(of otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return false
    }

    func isGestureRecognizerAllowed(gr: UIGestureRecognizer) -> Bool {
        return gr.view!.isDescendant(of: self.view!)
    }

    @objc func __dummyAction() {
        // do nothing
        // print("dummyAction")
    }
}
Onstad answered 27/8, 2018 at 13:58 Comment(1)
N
1

Just stumbled onto this randomly. I actually implemented this kind of text/sticker/overlay interaction for Instagram's Stories when that product launched. Anyone looking to do this kind of thing in their app should keep a few considerations in mind to get the best result:

  • You will need 3 gesture recognizers: a UIPanGestureRecognizer, a UIPinchGestureRecognizer, and a UIRotationGestureRecognizer.
  • The gestures need to be marked as working simultaneously via the UIGestureRecognizerDelegate protocol.
  • Consider installing the gesture recognizers to the parent container, rather than on the individual item views. If the gestures are installed on each movable view, all touches to move that view will be required to be inside of the movable view's bounds (or satisfy any internal conditions defined in the item view's pointInside:withEvent: method). When item views are of a smaller size, this requirement can make them harder to grab. If the gesture recognizers are instead installed on the container view, the container can decide at gesture time which item should start moving with the gestures.
  • Unintuitively it's actually much easier to reason about how the gestures are working together if they run through the same gesture recognition callback method. This will become important (and more apparent) below.
  • At each call of the method combining all gesture recognition, apply the cumulative translation, rotation, and scale to a fresh identity transform. In practice there is an odd disconnect between the user's gestures and the underlying transform. In the end we want the item view to stick underneath the user's fingers at all time. So, we must be careful with the order by which translation, rotation, and scale are applied because these attributes can affect each other. For example, by default translation of a CGAffineTransform is done relative to any existing rotation. So, if we apply positive x translation to a transform that's already been rotated 180 degrees, the corresponding view will appear to move to the user's left. So, when a gesture starts, save the state of the view's corresponding transform attribute. Then on gesture updates add the gestures cumulative update to the saved state. At the end of each gesture callback apply the cumulative translation, rotation, and scale (in that order) to a fresh identity transform, and apply that transform to the view.
  • Finally, and this is surprisingly important to make this overall interaction feel right, always update the anchorPoint of the view to be the center point of there user's fingers (provided by each gesture recognizer's locationInView: method). But default a CALayer (and by extension UIView) will rotate and scale about its center, but we want to ensure that it feels like the user is controlling the point of rotation and scale. Be aware that CALayer's position is directly affected by its anchorPoint, so its position will need to be offset under the hood so that the user doesn't experience a jump in position.

It's a lot to get this interaction working right, but when done correctly it feels like butter to the end user.

Nimbus answered 12/7, 2024 at 15:40 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.