mirror of
https://github.com/yattee/yattee.git
synced 2026-10-25 06:02:57 +00:00
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.
169 lines
6.5 KiB
Swift
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
|