Search code examples
iosuiscrollviewuikituigesturerecognizeruipangesturerecognizer

Pan view using UIPanGestureRecognizer within a functional UIScrollView


The Problem

I have a UIScrollView containing a UIView that I wish to allow the user to pan using a UIPanGestureRecognizer.

In order for this to work as desired, users should be able to pan the view with one finger, but also be able to pan the scroll view with another finger - doing both at the same time (using one finger for each).

However, the scroll view ceases to work when the user is panning a view contained within it. It cannot be panned until the view's pan gesture ends.

Attempted Workaround

I tried to work around this by enabling simultaneous scrolling of both the pan view and the UIScrollView that contains it by overriding the following UIGestureRecognizerDelegate method:

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

However, this makes it so that panning the view also moves the scroll view. Each element's panning gesture should be independent of the other, not linked.

Demo Project

I have created a simple demo project that should demonstrate this, here:

https://github.com/jeffc-dev/ScrollViewPannerTest

This project contains a scroll view with a square view that should be able to be panned independently of its containing scroll view, but can not.

Why I'm Doing This

The point of this is to make it easier/quicker for a user to find a destination to pan the view to. The is somewhat analogous to rearranging icons in Springboard: You can use one finger to pan an app icon while simultaneously panning between pages with another finger, quickly finding a place to drop it. I'm not using a paged scroll view - just a normal one - and I want it to be a seamless panning gesture (I don't need/want the user to have to enter a 'wiggle mode') but the basic principle is the same.


UPDATE: DonMag helpfully came up with the idea of using a UILongPressGestureRecognizer to move the view out of the scroll view for panning, which does seem promising. However, if I went that route I think I'd need to seamlessly transition to using a UIPanGestureRecognizer after doing so (as I do use some pan gesture recognizer-specific functionality).


Solution

  • I'm sure there are different ways to do this, but here is one approach...

    Instead of using a UIPanGesture I used a UILongPressGesture.

    When the gesture begins, we move the view from the scrollView to its superview. While we continue to press the view and drag it around, it is now independent of the scrollView. When we end the gesture (lift the finger), we add the view back to the scrollView.

    While dragging, we can use a second finger to scroll the content of the scroll view.

    The main portion of the code looks like this:

    @objc func handleLongPress(_ g: UILongPressGestureRecognizer) -> Void {
        
        switch g.state {
        
        case .began:
            
            // get our superview and its superview
            guard let sv = superview as? UIScrollView,
                  let ssv = sv.superview
            else {
                return
            }
            theScrollView = sv
            theRootView = ssv
            
            // convert center coords
            let cvtCenter = theScrollView.convert(self.center, to: theRootView)
            self.center = cvtCenter
            curCenter = self.center
            
            // add self to ssv (removes self from sv)
            ssv.addSubview(self)
            
            // start wiggling anim
            startAnim()
            
            // inform the controller
            startCallback?(self)
            
        case .changed:
            
            guard let thisView = g.view else {
                return
            }
            
            // get the gesture point
            let point = g.location(in: thisView.superview)
            
            // Calculate new center position
            var newCenter = thisView.center;
            newCenter.x += point.x - curCenter.x;
            newCenter.y += point.y - curCenter.y;
            
            // Update view center
            thisView.center = newCenter
            curCenter = newCenter
            
            // inform the controller
            movedCallback?(self)
            
        default:
            
            // stop wiggle anim
            stopAnim()
            
            // convert center to scroll view (original superview) coords
            let cvtCenter = theRootView.convert(curCenter, to: theScrollView)
            
            // update center
            self.center = cvtCenter
            
            // add self back to scroll view
            theScrollView.addSubview(self)
            
            // inform the controller
            endedCallback?(self)
            
        }
        
    }
    

    I forked your GitHub repo and added a new controller to demonstrate: https://github.com/DonMag/ScrollViewPannerTest

    You'll see that it is just a Starting Point for this approach. The view being dragged (actually, in this demo, you can use two fingers to drag two views at the same time) uses closures to inform the controller about the dragging...

    Currently, "drag/drop" does not affect any other subviews in the scrollView. The only closure that does anything is the "ended" closure, at which point the controller re-calcs the scrollView's contentSize. The "moved" closure could be used to re-position views -- but that's another task.