Smooth player details panel drag on iOS

This commit is contained in:
Arkadiusz Fal
2026-04-20 01:18:06 +02:00
parent ad075319ee
commit 1ae73789a4
3 changed files with 23 additions and 7 deletions

View File

@@ -200,6 +200,12 @@ extension ExpandedPlayerSheet {
// Panel height when pinned (capped by maxPanelHeight for widescreen videos without description)
let naturalPanelHeight = screenHeight - fitHeight
let pinnedPanelHeight = min(naturalPanelHeight, maxPanelHeight)
let pillsOverlayOpacity: CGFloat = {
guard isPanelDragging, panelDragOffset > 0 else { return 1 }
let fadeDistance: CGFloat = 48
let progress = min(1, panelDragOffset / fadeDistance)
return 1 - progress
}()
// Video area height (space above panel) - may be larger than fitHeight when panel is capped
let videoAreaHeight = screenHeight - pinnedPanelHeight
@@ -413,6 +419,7 @@ extension ExpandedPlayerSheet {
} : nil,
playerControlsLayout: playerControlsLayout,
onFullscreen: { [self] in toggleFullscreen() },
pillsOverlayOpacity: pillsOverlayOpacity,
onDragChanged: { [self] offset in
// Set drag flags only on transition to avoid 120/sec @Observable writes
if !isPanelDragging {

View File

@@ -13,6 +13,8 @@ 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
@@ -30,6 +32,10 @@ struct OverscrollGestureView: UIViewRepresentable {
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 {
if let scrollView = Self.findScrollView(from: view) {
@@ -45,6 +51,11 @@ struct OverscrollGestureView: UIViewRepresentable {
context.coordinator.onDragChanged = onDragChanged
context.coordinator.onDragEnded = onDragEnded
guard isEnabled else {
context.coordinator.gestureHandler.detach()
return
}
// If not attached yet, try again
if context.coordinator.gestureHandler.scrollView == nil {
DispatchQueue.main.async {

View File

@@ -16,6 +16,7 @@ struct PortraitDetailsPanel: View {
let onChannelTap: (() -> Void)?
let playerControlsLayout: PlayerControlsLayout
let onFullscreen: (() -> Void)?
let pillsOverlayOpacity: CGFloat
// Drag gesture callbacks
var onDragChanged: ((CGFloat) -> Void)?
@@ -28,7 +29,6 @@ struct PortraitDetailsPanel: View {
@State private var scrollToTopTrigger: Bool = false
@State private var showQueueSheet: Bool = false
@State private var showPlaylistSheet: Bool = false
@State private var panelHeight: CGFloat = 0
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
private var accentColor: Color { settingsManager?.accentColor.color ?? .accentColor }
@@ -164,6 +164,7 @@ struct PortraitDetailsPanel: View {
.background {
// UIKit gesture handler for smooth overscroll-to-collapse
OverscrollGestureView(
isEnabled: !isDraggingHandle,
onDragChanged: { offset in
onDragChanged?(offset)
},
@@ -172,6 +173,7 @@ struct PortraitDetailsPanel: View {
}
)
}
.scrollDisabled(isDraggingHandle)
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { _, newValue in
@@ -238,6 +240,8 @@ struct PortraitDetailsPanel: View {
.overlay {
if !isCommentsExpanded {
pillsOverlay
.opacity(pillsOverlayOpacity)
.allowsHitTesting(pillsOverlayOpacity > 0.01)
}
}
// Expanded comments overlay
@@ -343,12 +347,6 @@ struct PortraitDetailsPanel: View {
.animation(isPanelDragging ? nil : .spring(response: 0.35, dampingFraction: 0.8), value: hasCommentsPill)
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill)
.animation(isPanelDragging ? nil : .easeInOut(duration: 0.2), value: isScrolled)
.onAppear {
panelHeight = geometry.size.height
}
.onChange(of: geometry.size.height) { _, newValue in
panelHeight = newValue
}
}
}