mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
The fullscreen toggle used to slide both panels off screen while the video frame grew into the freed space. Panels now stay at their home positions as layers under the video, and the video frame grows over them to hide and shrinks back to reveal. Both panels stay in the hierarchy while covered, so scroll position and loaded content survive toggles; hit testing, accessibility and the dismiss-pan exclusion frames are switched off while covered (PanelResizeGrabber gets a publishesFrame flag for this). Side column gestures (grabber overdrag, trailing edge swipe) drive the video's trailing edge over the static column. The below panel's handle drag keeps sliding the panel off the bottom, with the video growing in lockstep so the edges coincide. The below panel is raised above the video only while expanding over it, and drops back under it after the collapse spring has fully settled. Hiding the status bar in the fullscreen state changed the top safe area and reflowed the whole layout mid-transition. The layout now ignores the top safe area and pads by a fixed status bar height (remembered by StatusBarVisibilityController while the bar is visible); the fullscreen video grows up into that strip as part of the same animation.
157 lines
6.0 KiB
Swift
157 lines
6.0 KiB
Swift
//
|
|
// PanelResizeGrabber.swift
|
|
// Yattee
|
|
//
|
|
// A draggable handle for resizing a details panel's width.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS)
|
|
|
|
/// A draggable handle for resizing the panel width.
|
|
struct PanelResizeGrabber: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
@Binding var panelWidth: CGFloat
|
|
let minWidth: CGFloat
|
|
let maxWidth: CGFloat
|
|
let panelSide: FloatingPanelSide
|
|
let isPinned: Bool
|
|
@Binding var isDragActive: Bool
|
|
|
|
/// Width of the grabber hit area (hosts can narrow it, e.g. corner layout)
|
|
var hitWidth: CGFloat = PanelResizeGrabber.grabberHitWidth
|
|
|
|
/// Whether the strip frame is published to the dismiss-pan exclusion
|
|
/// (hosts pass false while the grabber is in the hierarchy but covered).
|
|
var publishesFrame: Bool = true
|
|
|
|
/// Overdrag past `minWidth`: reports how far (in points, ≥ 0) the finger
|
|
/// went beyond the minimum while the width stays clamped — hosts can
|
|
/// slide the panel off-screen with it. Reported every drag update, so a
|
|
/// finger coming back above the minimum reports 0 again.
|
|
var onOverdragChanged: ((CGFloat) -> Void)? = nil
|
|
/// Release after an overdrag: final overshoot and the predicted overshoot
|
|
/// (from the drag's projected end location) for flick detection.
|
|
var onOverdragEnded: ((_ overshoot: CGFloat, _ predictedOvershoot: CGFloat) -> Void)? = nil
|
|
|
|
@State private var isDragging = false
|
|
@State private var dragStartWidth: CGFloat? = nil
|
|
@State private var dragStartLocation: CGFloat? = nil
|
|
#if os(macOS)
|
|
@State private var isHovering = false
|
|
#endif
|
|
|
|
/// Width of the grabber hit area
|
|
static let grabberHitWidth: CGFloat = 20
|
|
|
|
/// Grabber fill color - needs to be visible against both light (pinned) and dark (floating) backgrounds
|
|
private var grabberColor: Color {
|
|
// Pinned matches the panel drag handle (secondary 0.4);
|
|
// floating (dark background) stays a bit lighter
|
|
let isActive = isDragging
|
|
return Color.secondary.opacity(isActive ? 1.0 : (isPinned ? 0.4 : 0.6))
|
|
}
|
|
|
|
var body: some View {
|
|
// Full-height container for expanded hit area
|
|
Rectangle()
|
|
.fill(Color.clear)
|
|
.frame(width: hitWidth)
|
|
.frame(maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
.overlay {
|
|
// Visual pill indicator (centered) — sized like the panel
|
|
// drag handle capsule (36x4), rotated vertical
|
|
Capsule()
|
|
.fill(grabberColor)
|
|
.frame(width: 4, height: 36)
|
|
}
|
|
.gesture(dragGesture)
|
|
#if os(iOS)
|
|
// Publish the strip frame (with slop) so the window-level dismiss
|
|
// pan never begins from a resize drag — a diagonal start would
|
|
// otherwise cancel the resize and dismiss the player instead
|
|
.background {
|
|
GeometryReader { proxy in
|
|
let frame = publishesFrame ? proxy.frame(in: .global).insetBy(dx: -8, dy: 0) : .zero
|
|
Color.clear
|
|
.onChange(of: frame, initial: true) { _, frame in
|
|
appEnvironment?.navigationCoordinator.panelResizeGrabberFrame = frame
|
|
}
|
|
.onDisappear {
|
|
appEnvironment?.navigationCoordinator.panelResizeGrabberFrame = .zero
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#if os(macOS)
|
|
.onHover { hovering in
|
|
isHovering = hovering
|
|
isDragActive = hovering || isDragging
|
|
if hovering {
|
|
NSCursor.resizeLeftRight.push()
|
|
} else {
|
|
NSCursor.pop()
|
|
}
|
|
}
|
|
#endif
|
|
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
|
}
|
|
|
|
private var dragGesture: some Gesture {
|
|
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
|
.onChanged { value in
|
|
// Capture initial state on first drag event
|
|
if dragStartWidth == nil {
|
|
dragStartWidth = panelWidth
|
|
dragStartLocation = value.location.x
|
|
}
|
|
|
|
isDragging = true
|
|
isDragActive = true
|
|
|
|
guard let startWidth = dragStartWidth,
|
|
let startLocation = dragStartLocation else { return }
|
|
|
|
// Calculate delta from absolute finger position (not translation)
|
|
let locationDelta = value.location.x - startLocation
|
|
|
|
// Apply delta based on panel side
|
|
let delta: CGFloat
|
|
if panelSide == .right {
|
|
delta = -locationDelta
|
|
} else {
|
|
delta = locationDelta
|
|
}
|
|
|
|
let newWidth = startWidth + delta
|
|
panelWidth = min(max(newWidth, minWidth), maxWidth)
|
|
onOverdragChanged?(max(0, minWidth - newWidth))
|
|
}
|
|
.onEnded { value in
|
|
if let onOverdragEnded,
|
|
let startWidth = dragStartWidth,
|
|
let startLocation = dragStartLocation {
|
|
let overshoot = { (locationX: CGFloat) -> CGFloat in
|
|
let locationDelta = locationX - startLocation
|
|
let delta = panelSide == .right ? -locationDelta : locationDelta
|
|
return max(0, minWidth - (startWidth + delta))
|
|
}
|
|
onOverdragEnded(overshoot(value.location.x), overshoot(value.predictedEndLocation.x))
|
|
}
|
|
dragStartWidth = nil
|
|
dragStartLocation = nil
|
|
isDragging = false
|
|
#if os(macOS)
|
|
isDragActive = isHovering
|
|
#else
|
|
isDragActive = false
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|