Files
yattee/Yattee/Views/Player/Gestures/OverscrollGestureHandler.swift
2026-02-08 18:33:56 +01:00

118 lines
3.8 KiB
Swift

//
// OverscrollGestureHandler.swift
// Yattee
//
// UIKit gesture handler for detecting overscroll pull-down gestures on UIScrollView.
// When user pulls down at scroll top, disables bounce and forwards drag events for smooth
// panel collapse animation.
//
#if os(iOS)
import UIKit
/// Coordinates overscroll detection on a UIScrollView, calling back during pull-down gestures
/// when the scroll is at top. Disables bounce during the gesture to allow smooth animation.
final class OverscrollGestureHandler: NSObject, UIGestureRecognizerDelegate {
// MARK: - Properties
weak var scrollView: UIScrollView?
var onDragChanged: ((CGFloat) -> Void)?
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
/// Whether we're currently tracking an overscroll gesture
private var isTracking = false
/// The pan gesture recognizer we add to the scroll view
private var panRecognizer: UIPanGestureRecognizer?
// MARK: - Setup
/// Attaches the pan gesture recognizer to the scroll view.
func attach(to scrollView: UIScrollView) {
detach()
self.scrollView = scrollView
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.delegate = self
scrollView.addGestureRecognizer(pan)
panRecognizer = pan
}
/// Removes the pan gesture recognizer from the scroll view.
func detach() {
if let recognizer = panRecognizer, let view = recognizer.view {
view.removeGestureRecognizer(recognizer)
}
panRecognizer = nil
scrollView = nil
isTracking = false
}
// MARK: - Gesture Handling
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let scrollView else { return }
switch gesture.state {
case .began:
// Start tracking - disable bounce so we can control the movement
isTracking = true
scrollView.bounces = false
case .changed:
let translation = gesture.translation(in: gesture.view)
// Only forward positive (pull down) translations
if translation.y > 0 {
onDragChanged?(translation.y)
}
case .ended, .cancelled:
// Re-enable bounce
scrollView.bounces = true
isTracking = false
let translation = gesture.translation(in: gesture.view)
let velocity = gesture.velocity(in: gesture.view)
// Calculate predicted end position
let decelerationTime: CGFloat = 0.3
let predicted = translation.y + velocity.y * decelerationTime
onDragEnded?(translation.y, predicted)
default:
break
}
}
// MARK: - UIGestureRecognizerDelegate
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
let scrollView else {
return false
}
let velocity = pan.velocity(in: pan.view)
// Only begin if:
// 1. Scroll view is at top (contentOffset.y <= 0)
// 2. User is pulling down (velocity.y > 0)
// 3. Vertical movement is dominant (to not interfere with horizontal scrolling)
let isAtTop = scrollView.contentOffset.y <= 0
let isPullingDown = velocity.y > 0
let isVerticalDominant = abs(velocity.y) > abs(velocity.x)
return isAtTop && isPullingDown && isVerticalDominant
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
// Don't allow simultaneous recognition - we take over when overscrolling
false
}
}
#endif