Files
yattee/Yattee/Views/Player/Panels/PanelResizeGrabber.swift
Arkadiusz Fal 05a03723f6 Rework corner layout with portrait-style info panel below the video
The corner layout now always renders the info page as a portrait-style
panel below the video (collapsible description, drag to hide/expand),
while the side column holds tabs for the remaining pages. Key pieces:

- Below-video panel reuses PortraitDetailsPanel (no comments pill,
  square corners, chapters/related stay in the side column tabs)
- Drag down hides the panel with the video recentering in lockstep;
  swipe up on the video reveals it following the finger
- Fullscreen button toggles the panels in corner layout (controls hide
  first so their resize never plays visibly); status bar and home
  indicator hide only while the panels are hidden
- Panel visibility and column width are seeded at init from a warmed
  PlayerControlsLayout cache, so the first frame resolves corner mode
  without flashing the default layout or animating the panels in
- Dismiss pan is blocked inside the below-video panel frame
  (cornerBelowPanelFrame in NavigationCoordinator)
- Resize grabber slimmed to match the panel drag handle look
2026-09-23 07:25:02 +02:00

114 lines
3.6 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 {
@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
@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(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)
}
.onEnded { _ in
dragStartWidth = nil
dragStartLocation = nil
isDragging = false
#if os(macOS)
isDragActive = isHovering
#else
isDragActive = false
#endif
}
}
}
#endif