So I have a UIScrollView
at the top of the view with a UIPanGestureRecognizer covering the whole view.
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.
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.
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:
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.