Instant Pan Gesture Interactions

May 13, 2023

Apple has a really awesome WWDC 2018 video called Designing Fluid Interfaces, and one of the key takeaways from the videos that one of the presenters, Chan Karunamuni, said is “Look for delays everywhere. Everything needs to respond instantly.” (6:28)

A really great example of this is a scroll view on iOS. If you flick through your contacts and touch your finger to the screen, instantly the scroll view stops and let’s you reposition it. This kind of instantaneous behavior is really important in our own views, interactions, and animations.

Okay, so I was building a view where I wanted this behavior. Basically, you flick a box from the left side of the screen to the right, and as you release your finger it keeps going. Of note though is that you should be able to grab the box while it’s in flight to stop it.

Turns out this is trickier than it seems, as UIPanGestureRecognizer has a small delay in startup where it requires you to move your finger before it recognizes the gesture starting. If you just touch your finger on the moving object, that’s not technically a “pan”, so it ignores you (makes sense), which means the object just keeps moving until you move enough to trigger a “pan”, the result of this is an interaction that doesn’t feel very responsive.

First attempt at a solution

One solution that works in many cases for many people is to implement an “InstantPanGestureRecognizer”, where unlike the normal one, it enters its “began” state as soon as a finger is touched down, allowing you to respond instantly to user input and pause the animation as soon as their finger touches the screen.

Nathan Gitter has an awesome article showing code examples of many of the interactions shown in Apple’s video. It’s an amazing article. He has a section on implementing this custom pan gesture:

class InstantPanGestureRecognizer: UIPanGestureRecognizer {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
        self.state = .began
    }
}

For awhile this worked, but something changed in a recent version of iOS that effectively broke it. On iPhones it mostly works fine, but on iPads, and on iPhones if the view is near the home indicator at the bottom, there’s a delay in between the state being updated to .began, and the pan gesture handler being notified of the state change. It’s about 0.75 seconds, which is an enormous amount of time for a gesture (on a 120 Hz device, that’s 90 frames rendered before your input is recognized!).

Based on console logs and inspecting the UIGestureRecognizers attached to the app’s UIWindow, it looks like there’s something called a “system gesture gate”, that I’m assuming gates off gestures from conflicting with system ones (particularly on the iPad, which has lots of multitasking gestures), which means ones attached to the app run at a lower priority and could be delayed.

(Note that the preferredScreenEdgesDeferringSystemGestures property does not seem to have an effect here.)

That being said, since the state does update instantly, it’s just the communication of the state change that’s delayed, it’s pretty easy to work around this and still have an InstantPanGestureRecognizer. Add the code for the custom gesture above, but instead of using addTarget(...) to listen for changes to the gesture recognizer, which has a delay in some cases, just jump straight to KVO:

static var gestureContext = 1
    
override func viewDidLoad() {
    super.viewDidLoad()
    
    let panGestureRecognizer = InstantPanGestureRecognizer()
    panGestureRecognizer.addObserver(self, forKeyPath: #keyPath(UIPanGestureRecognizer.state), options: .new, context: &Self.gestureContext)
    view.addGestureRecognizer(panGestureRecognizer)
}

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    guard context == &Self.gestureContext else {
        // Be sure to pass on any observes that we didn't explicitly ask for!
        super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
        return
    }
    
    guard let panGestureRecognizer = object as? UIPanGestureRecognizer else { return }
    
    switch panGestureRecognizer.state {
    case .began:
        // This is no longer delayed! Stop animation if has not already ended
    case .changed:
        // Update position of object
    case .ended:
        // Continue object's momentum with animation
    }
}

The .began state will now trigger without needing to move your finger first, and without delay on iPads.

But I think there’s an even better solution for some cases, read on!

A solution I like more

While that previous gesture setup works great, especially in the case Nathan Gitter outlined in the aforementioned tutorial, I think there’s one catch that could trip you up in some situations: pan gesture recognizers have a movement delay before starting for a reason. This small delay can be important, because it allows us to have some information about the pan by the time it starts, like which direction the user is moving in.

This movement data can be super handy, for, say, only making the gesture start if it’s moving horizontally. For example, say we have a box inside a scroll view, and we want to let the user pan the box from left to right, but still allow the scroll view to scroll up and down, we could look at our object’s pan gesture and only allow it to start if the movement is horizontal. (If the pan gesture were to start as soon as the user’s finger touches the screen, we won’t be able to know which direction they’re panning in to stop it.)

So while the small delay introduced in a recent OS version on iPads is unfortunate, it gives us an opportunity to look outside of an instant pan gesture recognizer and its limitations.

What would that be? Something that would allow the pan gesture recognizer to operate normally, but have a secondary system that allows us to know as soon as the user touches down on the screen.

There’s a few ways to do this, but since we’re describing an interaction, I think an additional gesture recognizer would work perfectly here. UITapGestureRecognizer immediately comes to mind, but requires the user’s finger to lift up afterward, which rules it out for us (we want to be able to just touch down to pause, then pan around if desired). UILongPressGestureRecognizer could actually work here if we set minimumPressDuration to a super small number, but that feels kinda hacky (it’s not really a long press at that point, is it)?

Okay, so let’s build our own. It’s actually really simple:

class TouchesBeganGestureRecognizer: UIGestureRecognizer {
    override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool {
        return false
    }
    
    override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool {
        return false
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesBegan(touches, with: event)
        
        state = .began
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesMoved(touches, with: event)
        
        state = .ended
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesMoved(touches, with: event)
        
        state = .cancelled
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
        super.touchesEnded(touches, with: event)
        
        state = .ended
    }
}

We create a custom gesture recognizer that both can’t be prevented by other gesture recognizers, and can’t prevent others (this means it works on top of other gesture recognizers). And as far as states goes, it enters the .began state as soon as the user touches down, but as soon as anything else happens, it ends (there’s no .changed state).

Then we can take this new gesture and add it in addition to our pan gesture. Here’s a simple example showing this, where we use a pan gesture to move a box, when the pan gesture ends the box continues moving in the direction the user threw it, and they can then instantly grab it while it’s in flight to stop it and/or start moving it around again. Note that for sake of keeping the example short, the direction is only left-to-right, and the gesture’s velocity is not calculated into the animation (for the latter Nathan’s article above goes over conserving momentum as well if you’re curious!).

class ViewController: UIViewController {
    let box = UIView(frame: CGRect(x: 0.0, y: 200.0, width: 200.0, height: 200.0))
    var propertyAnimator: UIViewPropertyAnimator?

    override func viewDidLoad() {
        super.viewDidLoad()
        
        box.backgroundColor = .systemOrange
        view.addSubview(box)
        
        let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureUpdated(panGestureRecognizer:)))
        box.addGestureRecognizer(panGestureRecognizer)
        
        let touchesBeganGestureRecognizer = TouchesBeganGestureRecognizer(target: self, action: #selector(touchesBeganGestureRecognizerUpdated(touchesBeganGestureRecognizer:)))
        box.addGestureRecognizer(touchesBeganGestureRecognizer)
    }
    
    @objc private func panGestureUpdated(panGestureRecognizer: UIPanGestureRecognizer) {
        if panGestureRecognizer.state == .began {
            // Normalize the gesture's starting translation with where the box is at the start
            panGestureRecognizer.setTranslation(CGPoint(x: box.frame.origin.x, y: 0.0), in: box)
        } else if panGestureRecognizer.state == .changed {
            box.frame.origin.x = panGestureRecognizer.translation(in: box).x
        } else if panGestureRecognizer.state == .ended {
            let propertyAnimator = UIViewPropertyAnimator(duration: 1.5, curve: .linear)
            self.propertyAnimator = propertyAnimator
            
            propertyAnimator.addAnimations {
                self.box.frame.origin.x = self.view.bounds.width - self.box.bounds.width
            }
            
            propertyAnimator.addCompletion { position in
                self.propertyAnimator = nil
            }
            
            propertyAnimator.startAnimation()
        }
    }
    
    @objc private func touchesBeganGestureRecognizerUpdated(touchesBeganGestureRecognizer: TouchesBeganGestureRecognizer) {
        guard touchesBeganGestureRecognizer.state == .began else { return }
        guard let propertyAnimator else { return }
        
        propertyAnimator.stopAnimation(true)
        
        self.propertyAnimator = nil
    }
}

There you go! Hopefully that gives you a nice way of making a pan gesture animation feel even more responsive.