mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 19:05:03 +00:00
Smooth player details panel drag on iOS
This commit is contained in:
@@ -200,6 +200,12 @@ extension ExpandedPlayerSheet {
|
|||||||
// Panel height when pinned (capped by maxPanelHeight for widescreen videos without description)
|
// Panel height when pinned (capped by maxPanelHeight for widescreen videos without description)
|
||||||
let naturalPanelHeight = screenHeight - fitHeight
|
let naturalPanelHeight = screenHeight - fitHeight
|
||||||
let pinnedPanelHeight = min(naturalPanelHeight, maxPanelHeight)
|
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
|
// Video area height (space above panel) - may be larger than fitHeight when panel is capped
|
||||||
let videoAreaHeight = screenHeight - pinnedPanelHeight
|
let videoAreaHeight = screenHeight - pinnedPanelHeight
|
||||||
@@ -413,6 +419,7 @@ extension ExpandedPlayerSheet {
|
|||||||
} : nil,
|
} : nil,
|
||||||
playerControlsLayout: playerControlsLayout,
|
playerControlsLayout: playerControlsLayout,
|
||||||
onFullscreen: { [self] in toggleFullscreen() },
|
onFullscreen: { [self] in toggleFullscreen() },
|
||||||
|
pillsOverlayOpacity: pillsOverlayOpacity,
|
||||||
onDragChanged: { [self] offset in
|
onDragChanged: { [self] offset in
|
||||||
// Set drag flags only on transition to avoid 120/sec @Observable writes
|
// Set drag flags only on transition to avoid 120/sec @Observable writes
|
||||||
if !isPanelDragging {
|
if !isPanelDragging {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import UIKit
|
|||||||
/// A transparent view that finds its parent UIScrollView and attaches an overscroll gesture handler.
|
/// 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.
|
/// Use as a `.background` on a SwiftUI `ScrollView` to intercept pull-down gestures at scroll top.
|
||||||
struct OverscrollGestureView: UIViewRepresentable {
|
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)
|
/// Called during the drag with the vertical translation (positive = pulling down)
|
||||||
var onDragChanged: ((CGFloat) -> Void)?
|
var onDragChanged: ((CGFloat) -> Void)?
|
||||||
/// Called when the drag ends with translation and predicted end translation
|
/// Called when the drag ends with translation and predicted end translation
|
||||||
@@ -30,6 +32,10 @@ struct OverscrollGestureView: UIViewRepresentable {
|
|||||||
context.coordinator.onDragChanged = onDragChanged
|
context.coordinator.onDragChanged = onDragChanged
|
||||||
context.coordinator.onDragEnded = onDragEnded
|
context.coordinator.onDragEnded = onDragEnded
|
||||||
|
|
||||||
|
guard isEnabled else {
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule scroll view discovery after view is in hierarchy
|
// Schedule scroll view discovery after view is in hierarchy
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let scrollView = Self.findScrollView(from: view) {
|
if let scrollView = Self.findScrollView(from: view) {
|
||||||
@@ -45,6 +51,11 @@ struct OverscrollGestureView: UIViewRepresentable {
|
|||||||
context.coordinator.onDragChanged = onDragChanged
|
context.coordinator.onDragChanged = onDragChanged
|
||||||
context.coordinator.onDragEnded = onDragEnded
|
context.coordinator.onDragEnded = onDragEnded
|
||||||
|
|
||||||
|
guard isEnabled else {
|
||||||
|
context.coordinator.gestureHandler.detach()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If not attached yet, try again
|
// If not attached yet, try again
|
||||||
if context.coordinator.gestureHandler.scrollView == nil {
|
if context.coordinator.gestureHandler.scrollView == nil {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct PortraitDetailsPanel: View {
|
|||||||
let onChannelTap: (() -> Void)?
|
let onChannelTap: (() -> Void)?
|
||||||
let playerControlsLayout: PlayerControlsLayout
|
let playerControlsLayout: PlayerControlsLayout
|
||||||
let onFullscreen: (() -> Void)?
|
let onFullscreen: (() -> Void)?
|
||||||
|
let pillsOverlayOpacity: CGFloat
|
||||||
|
|
||||||
// Drag gesture callbacks
|
// Drag gesture callbacks
|
||||||
var onDragChanged: ((CGFloat) -> Void)?
|
var onDragChanged: ((CGFloat) -> Void)?
|
||||||
@@ -28,7 +29,6 @@ struct PortraitDetailsPanel: View {
|
|||||||
@State private var scrollToTopTrigger: Bool = false
|
@State private var scrollToTopTrigger: Bool = false
|
||||||
@State private var showQueueSheet: Bool = false
|
@State private var showQueueSheet: Bool = false
|
||||||
@State private var showPlaylistSheet: Bool = false
|
@State private var showPlaylistSheet: Bool = false
|
||||||
@State private var panelHeight: CGFloat = 0
|
|
||||||
|
|
||||||
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
||||||
private var accentColor: Color { settingsManager?.accentColor.color ?? .accentColor }
|
private var accentColor: Color { settingsManager?.accentColor.color ?? .accentColor }
|
||||||
@@ -164,6 +164,7 @@ struct PortraitDetailsPanel: View {
|
|||||||
.background {
|
.background {
|
||||||
// UIKit gesture handler for smooth overscroll-to-collapse
|
// UIKit gesture handler for smooth overscroll-to-collapse
|
||||||
OverscrollGestureView(
|
OverscrollGestureView(
|
||||||
|
isEnabled: !isDraggingHandle,
|
||||||
onDragChanged: { offset in
|
onDragChanged: { offset in
|
||||||
onDragChanged?(offset)
|
onDragChanged?(offset)
|
||||||
},
|
},
|
||||||
@@ -172,6 +173,7 @@ struct PortraitDetailsPanel: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.scrollDisabled(isDraggingHandle)
|
||||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||||
geometry.contentOffset.y
|
geometry.contentOffset.y
|
||||||
} action: { _, newValue in
|
} action: { _, newValue in
|
||||||
@@ -238,6 +240,8 @@ struct PortraitDetailsPanel: View {
|
|||||||
.overlay {
|
.overlay {
|
||||||
if !isCommentsExpanded {
|
if !isCommentsExpanded {
|
||||||
pillsOverlay
|
pillsOverlay
|
||||||
|
.opacity(pillsOverlayOpacity)
|
||||||
|
.allowsHitTesting(pillsOverlayOpacity > 0.01)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Expanded comments overlay
|
// 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.35, dampingFraction: 0.8), value: hasCommentsPill)
|
||||||
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill)
|
.animation(isPanelDragging ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill)
|
||||||
.animation(isPanelDragging ? nil : .easeInOut(duration: 0.2), value: isScrolled)
|
.animation(isPanelDragging ? nil : .easeInOut(duration: 0.2), value: isScrolled)
|
||||||
.onAppear {
|
|
||||||
panelHeight = geometry.size.height
|
|
||||||
}
|
|
||||||
.onChange(of: geometry.size.height) { _, newValue in
|
|
||||||
panelHeight = newValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user