0

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.

HangarRash
  • 7,314
  • 5
  • 5
  • 32
Gizmodo
  • 3,151
  • 7
  • 45
  • 92
  • 1
    https://stackoverflow.com/a/51070947/6630644 – SPatel Feb 20 '23 at 16:23
  • So, here we have a main view that includes a scroll view and a pan gesture. The scroll view does no span across the entire main view, but the pan gesture does. – Gizmodo Feb 20 '23 at 16:49
  • 1
    @Gizmodo - there are probably several approaches... but, what is your actual goal? Do you only want the scroll content to move with the drag? Or, do you want it to function as if the whole screen is a scroll view - complete with "normal" scroll animation deceleration - with only a "window" of visibility? – DonMag Feb 20 '23 at 19:43
  • @DonMag - Think of it as, I have a very small ScrollView, I want to have the user scroll it by dragging anywhere on the screen, in this case, well outside the boundaries of the actual ScrollView. – Gizmodo Feb 20 '23 at 21:24

2 Answers2

1

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.

DonMag
  • 69,424
  • 5
  • 50
  • 86
1

The WWDC video I was referring to above was "`Advanced Scrollviews and Touch Handling Techniques" from 2014. For some reason, Apple has removed the session.

https://www.wwdcnotes.com/notes/wwdc14/235/

The key lines here were:

self.view.addGestureRecognizer(myScrollView.panGestureRecognizer)

They are adding the scrollview's pan gesture to another view.

Gizmodo
  • 3,151
  • 7
  • 45
  • 92