iosswiftuiscrollviewuipangesturerecognizer

Scroll UIScrollView from an external UIPanGestureRecognizer


enter image description here

SETUP

So I have a UIScrollView at the top of the view with a UIPanGestureRecognizer covering the whole view.

GOAL

When the user pan with a touch, I want the UIScrollView to scroll in a way that the pan gesture within the scroll view picks up the touches.

NOTE

A few years back, on a WWDC session video, Apple showed us how to do this using the pull-down gesture to search on the Home Screen. They showed how the pull-down pan gesture actually gets picked up by the scroll view inside the search results panel. Somehow, now I can't locate this video.


Solution

  • As I mentioned in the comments, there are several approaches to this... which to use depends on what else you need to do with other UI elements.

    So, absent any additional information, here are two ways to go about this.

    Let's start with a "Base" view controller class:

    class ScrollTestBaseVC: UIViewController {
        
        var scrollView: UIScrollView!
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // scrollView will be set by the subclass
            //  if it is not, that means we are running THIS view controller
            if scrollView == nil {
                scrollView = UIScrollView()
            }
            
            // vertical stack view to use as the scroll content
            let stackView = UIStackView()
            stackView.axis = .vertical
            stackView.spacing = 40.0
            
            // 12 labels in the stack view
            for i in 1...12 {
                let v = UILabel()
                v.backgroundColor = .yellow
                v.text = "Label \(i)"
                stackView.addArrangedSubview(v)
            }
            
            // add stack view to scroll view
            stackView.translatesAutoresizingMaskIntoConstraints = false
            scrollView.addSubview(stackView)
            
            // add scroll view to view
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(scrollView)
            
            let g = view.safeAreaLayoutGuide
            let cg = scrollView.contentLayoutGuide
            let fg = scrollView.frameLayoutGuide
            
            NSLayoutConstraint.activate([
                
                scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                scrollView.heightAnchor.constraint(equalToConstant: 240.0),
                
                stackView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
                stackView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
                stackView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
                stackView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
                
                stackView.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -16.0),
                
            ])
            
            // so we can see framing
            stackView.backgroundColor = .systemBlue
            scrollView.backgroundColor = .systemGreen
            
        }
        
    }
    

    It looks like this when run by itself:

    enter image description here

    A scroll view with green background (so we can see it)... the scroll content is a vertical stack view with 12 labels (so we have something to scroll).

    So far, nothing new. You can scroll the scroll view, but only dragging inside it.


    The First approach is to create a subclass of that "base" controller, but we'll add a UIPanGestureRecognizer to the controller's view:

    class PanScrollVC: ScrollTestBaseVC {
        
        // we use this to track the start of the pan gesture
        var currY: CGFloat = 0
        
        override func viewDidLoad() {
            
            // use a default UIScrollView
            scrollView = UIScrollView()
            
            super.viewDidLoad()
            
            // add pan gesture to view
            let p = UIPanGestureRecognizer(target: self, action: #selector(panHandler(_:)))
            view.addGestureRecognizer(p)
            
        }
        
        @objc func panHandler(_ gesture: UIPanGestureRecognizer) {
            
            let translation = gesture.translation(in: view)
            
            if gesture.state == .began {
                
                // save current scroll Y offset
                currY = scrollView.contentOffset.y
                
            } else if gesture.state == .changed {
                
                // move scroll view content while dragging
                scrollView.contentOffset.y = currY - translation.y
                
            } else {
                
                // finished dragging
                
                // this will "bounce" the content if we've
                //  dragged past the top or bottom
                //
                // if you want to mimic a scroll view's deceleration
                //  you'd need to implement the gesture's velocity, magnitude, etc
                
                let mxY: CGFloat = scrollView.contentSize.height - scrollView.frame.height
                let finalY = min(mxY, max(scrollView.contentOffset.y, 0.0))
                
                UIView.animate(withDuration: 0.3, animations: {
                    self.scrollView.contentOffset.y = finalY
                })
                
            }
            
        }
        
    }
    

    Now, dragging anywhere outside the scroll view will set the content offset of the scroll view's content, so it will look like you are dragging inside it.

    Note that this example will "bounce" the content if we drag past the top or bottom, but - since you didn't say anything about your goal - it does not mimic a scroll view's deceleration. You could do that by calculating the gesture's velocity, magnitude, etc.


    A Second approach would be to subclass UIScrollView and override hitTest(...):

    class HTScrollView: UIScrollView {
        override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            return self
        }
    }
    

    So this controller (again subclassing the "base" controller) uses a HTScrollView in place of the default UIScrollView:

    class CustomScrollVC: ScrollTestBaseVC {
        
        override func viewDidLoad() {
            
            scrollView = HTScrollView()
            
            super.viewDidLoad()
            
        }
        
    }
    

    Again, dragging anywhere outside the scroll view will look just like you are dragging inside it... with the added advantage of getting the built-in deceleration and bouncing.

    The drawback here is that you won't be able to interact with any subviews in the scroll view itself - such as buttons.

    To overcome that, we'd need to use a more complete hitTest(...) override which would loop through the scroll view's subviews and return the view/control appropriately.