Files
yattee/Yattee/Views/Player/Gestures/OverscrollGestureView.swift
Arkadiusz Fal 1fe1c2cd74 Attach the overscroll collapse gesture to the geometrically matching scroll view
The handler picked the first scroll view under the nearest ancestor,
which in the corner layout is one of the side column pages, so pulling
down on the expanded below-video panel never collapsed it. Match the
background view's window frame instead and re-attach when the match
changes.
2026-09-25 19:57:33 +02:00

169 lines
6.5 KiB
Swift

//
// OverscrollGestureView.swift
// Yattee
//
// UIViewRepresentable that attaches an OverscrollGestureHandler to a parent UIScrollView.
// Place this as a background on a SwiftUI ScrollView to intercept pull-down overscroll gestures.
//
#if os(iOS)
import SwiftUI
import UIKit
/// A transparent view that finds its parent UIScrollView and attaches an overscroll gesture handler.
/// Use as a `.background` on a SwiftUI `ScrollView` to intercept pull-down gestures at scroll top.
struct OverscrollGestureView: UIViewRepresentable {
/// Whether the handler should currently be attached.
var isEnabled: Bool = true
/// Called during the drag with the vertical translation (positive = pulling down)
var onDragChanged: ((CGFloat) -> Void)?
/// Called when the drag ends with translation and predicted end translation
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
// Store callbacks on coordinator
context.coordinator.onDragChanged = onDragChanged
context.coordinator.onDragEnded = onDragEnded
guard isEnabled else {
return view
}
// Schedule scroll view discovery after view is in hierarchy
DispatchQueue.main.async {
Self.attachIfNeeded(from: view, coordinator: context.coordinator)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
// Update callbacks
context.coordinator.onDragChanged = onDragChanged
context.coordinator.onDragEnded = onDragEnded
guard isEnabled else {
context.coordinator.gestureHandler.detach()
return
}
// Attach if not yet attached, or re-attach when the geometric match
// changed (the hierarchy is flat under the SwiftUI host, so the
// matching scroll view can differ from the one first found)
DispatchQueue.main.async {
Self.attachIfNeeded(from: uiView, coordinator: context.coordinator)
}
}
private static func attachIfNeeded(from view: UIView, coordinator: Coordinator) {
guard view.window != nil else { return }
// Cheap check first: keep the current scroll view while it still
// matches our frame (updateUIView runs on every SwiftUI update)
if let attached = coordinator.gestureHandler.scrollView, attached.window != nil {
let hostFrame = view.convert(view.bounds, to: nil)
let frame = attached.convert(attached.bounds, to: nil)
if abs(frame.minX - hostFrame.minX) < 1, abs(frame.minY - hostFrame.minY) < 1,
abs(frame.width - hostFrame.width) < 1 {
return
}
}
guard let scrollView = findScrollView(from: view) else { return }
if coordinator.gestureHandler.scrollView !== scrollView {
LoggingService.shared.debug(
"OverscrollGestureView attach",
category: .player,
details: "scrollView=\(Unmanaged.passUnretained(scrollView).toOpaque()) frame=\(scrollView.convert(scrollView.bounds, to: nil)) host=\(view.convert(view.bounds, to: nil))"
)
coordinator.gestureHandler.attach(to: scrollView)
}
}
static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
coordinator.gestureHandler.detach()
}
// MARK: - Scroll View Discovery
/// Finds the UIScrollView this view is the `.background` of.
/// `.background` creates a separate branch, and SwiftUI hosts platform
/// views in a mostly flat hierarchy, so a common ancestor can hold many
/// unrelated scroll views (corner layout: side column pages + below-video
/// panel). A background view shares its frame with the view it backs, so
/// pick the scroll view whose window frame matches ours best.
private static func findScrollView(from view: UIView) -> UIScrollView? {
let hostFrame = view.convert(view.bounds, to: nil)
// Collect all ancestors
var ancestors: [UIView] = []
var current: UIView? = view.superview
while let parent = current {
ancestors.append(parent)
current = parent.superview
}
// Search from each ancestor level, starting closest
for ancestor in ancestors {
var scrollViews: [UIScrollView] = []
collectScrollViews(in: ancestor, into: &scrollViews)
guard !scrollViews.isEmpty else { continue }
// Best geometric match: smallest frame mismatch against our own
// frame, among scroll views that actually overlap it. Ignore tiny
// internal scroll views.
let candidates = scrollViews.filter { $0.frame.height > 100 }
let scored = (candidates.isEmpty ? scrollViews : candidates).compactMap { scrollView -> (UIScrollView, CGFloat)? in
let frame = scrollView.convert(scrollView.bounds, to: nil)
guard hostFrame.isEmpty || frame.intersects(hostFrame) else { return nil }
let mismatch = abs(frame.minX - hostFrame.minX) + abs(frame.minY - hostFrame.minY)
+ abs(frame.width - hostFrame.width) + abs(frame.height - hostFrame.height)
return (scrollView, mismatch)
}
if let best = scored.min(by: { $0.1 < $1.1 }) {
return best.0
}
// Nothing overlaps at this level (e.g. not laid out yet) — keep
// climbing; fall back to the old behavior at the root.
if ancestor === ancestors.last {
return candidates.first ?? scrollViews.first
}
}
return nil
}
private static func collectScrollViews(in view: UIView, into result: inout [UIScrollView]) {
for subview in view.subviews {
if let scrollView = subview as? UIScrollView {
result.append(scrollView)
}
collectScrollViews(in: subview, into: &result)
}
}
// MARK: - Coordinator
final class Coordinator {
let gestureHandler = OverscrollGestureHandler()
var onDragChanged: ((CGFloat) -> Void)? {
didSet {
gestureHandler.onDragChanged = onDragChanged
}
}
var onDragEnded: ((CGFloat, CGFloat) -> Void)? {
didSet {
gestureHandler.onDragEnded = onDragEnded
}
}
}
}
#endif