feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
||||
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
||||
// by a controller (iOS/iPadOS only).
|
||||
//
|
||||
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||
// container width, so it's correct in an iPad split view too) lets the first and last item reach the
|
||||
// middle. The CALLER owns each card's look, including its own `.scrollTransition` — this component
|
||||
// deliberately applies none, so a screen can chain the VisualEffect-only transition modifiers without
|
||||
// the generic wrapper here pushing the type-checker onto an overload it can't satisfy.
|
||||
//
|
||||
// Navigation authority: an internal `cursor` (an index), NOT the scroll-position binding, is the
|
||||
// source of truth for where the gamepad is. `.scrollPosition(id:)` is a two-way binding and the
|
||||
// scroll view WRITES intermediate ids into it while a programmatic animation is in flight — so
|
||||
// reading the "current" item back out of it to compute the next one desyncs badly on a fast held
|
||||
// stick (each move reads a lagging value and the cursor stalls before the last item). Instead a move
|
||||
// advances `cursor` synchronously and points the scroll view at `items[cursor]`; scroll read-back is
|
||||
// only allowed to move the cursor when the gamepad hasn't driven recently (i.e. a touch drag).
|
||||
//
|
||||
// Feedback is dual-channel by design: `.sensoryFeedback` ticks the DEVICE Taptic engine (for a
|
||||
// handheld/touch user) and `MenuHaptics` ticks the CONTROLLER (for a couch user holding the pad).
|
||||
// Both fire on a move, on confirm, and — for a non-wrapping list — a duller bump plus a short visual
|
||||
// recoil when a move is refused at either end.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||
let items: [Item]
|
||||
/// Output only: the carousel WRITES the focused item's id here for the caller's detail panel.
|
||||
/// It is deliberately not what drives the scroll (see the file header).
|
||||
@Binding var selection: Item.ID?
|
||||
/// Every card is laid out at this fixed width so `.viewAligned` snapping + symmetric side
|
||||
/// insets center exactly one at a time.
|
||||
let itemWidth: CGFloat
|
||||
let spacing: CGFloat
|
||||
/// A → activate the centered item.
|
||||
let onActivate: (Item) -> Void
|
||||
/// Y → the screen's secondary action (e.g. open a host's library); nil disables it.
|
||||
var onSecondary: (() -> Void)?
|
||||
/// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to).
|
||||
var onBack: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
|
||||
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||
/// something is presented on top so only the front-most carousel consumes the gamepad.
|
||||
var isActive: Bool = true
|
||||
@ViewBuilder let card: (Item) -> Card
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||
/// while the gamepad is driving — that's the whole desync fix.
|
||||
@State private var cursor = 0
|
||||
/// The id the scroll view is aligned to — its own two-way `.scrollPosition` state.
|
||||
@State private var scrolledID: Item.ID?
|
||||
/// When the gamepad last moved the cursor; gates scroll read-back so a mid-animation write can't
|
||||
/// drag the cursor backward during a fast held direction.
|
||||
@State private var lastNav = Date.distantPast
|
||||
/// True while a programmatic scroll animation is in flight. `.scrollPosition(id:)` DROPS a new
|
||||
/// write that lands mid-animation — the scroll view stays stuck on the old item even though the
|
||||
/// binding updated — so we never issue one until the previous animation reports complete, then
|
||||
/// `commitScroll` re-targets the current cursor (coalescing a fast burst; see `commitScroll`).
|
||||
@State private var isScrolling = false
|
||||
/// A short horizontal recoil when a move is refused at a list end.
|
||||
@State private var bumpOffset: CGFloat = 0
|
||||
/// `.sensoryFeedback` fires on a change of its trigger; counters request a device tick for the
|
||||
/// confirm and end-stop events (moves trigger on `cursor`).
|
||||
@State private var activateTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
|
||||
/// Read-back from a touch drag is honoured only once the gamepad has been quiet this long
|
||||
/// (longer than a move animation, so overlapping held-stick moves never let it through).
|
||||
private let navSettle: TimeInterval = 0.4
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(items) { item in
|
||||
card(item)
|
||||
.frame(width: itemWidth)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { tap(item) }
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollPosition(id: $scrolledID)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
.onAppear {
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
// Hand controller input to/from a screen presented on top (see `isActive`): a covered
|
||||
// carousel stops polling so it can't navigate behind the front-most one.
|
||||
.onChange(of: isActive) { _, active in
|
||||
if active {
|
||||
wire()
|
||||
input.start()
|
||||
} else {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
}
|
||||
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
||||
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
||||
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
||||
// — which never sets `isScrolling` — moves the cursor here.
|
||||
.onChange(of: scrolledID) { _, newValue in
|
||||
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||
cursor = idx
|
||||
selection = newValue
|
||||
}
|
||||
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
||||
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||
// stale in the closures stored on `input`).
|
||||
.onChange(of: items.map(\.id)) { _, _ in
|
||||
reconcile()
|
||||
wire()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input wiring
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
input.onSecondary = onSecondary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
let forward = direction == .right || direction == .down
|
||||
step(by: forward ? 1 : -1, clampAtEnds: false)
|
||||
}
|
||||
|
||||
private func shoulder(right: Bool) {
|
||||
step(by: right ? shoulderJump : -shoulderJump, clampAtEnds: true)
|
||||
}
|
||||
|
||||
/// Advance the cursor by `delta`. A single move (`clampAtEnds: false`) that would leave the list
|
||||
/// recoils + bumps; a shoulder jump (`clampAtEnds: true`) lands on the end item, bumping only if
|
||||
/// already there. The cursor is the authority — the scroll view is pointed at it, never read for it.
|
||||
private func step(by delta: Int, clampAtEnds: Bool) {
|
||||
guard !items.isEmpty else { return }
|
||||
var target = cursor + delta
|
||||
if target < 0 || target >= items.count {
|
||||
guard clampAtEnds else { return boundaryBump(forward: delta > 0) }
|
||||
target = min(max(target, 0), items.count - 1)
|
||||
}
|
||||
guard target != cursor else { return boundaryBump(forward: delta > 0) }
|
||||
cursor = target
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = items[target].id // text/detail updates immediately; the scroll chases
|
||||
commitScroll()
|
||||
}
|
||||
|
||||
private let scrollAnim: TimeInterval = 0.24
|
||||
/// A hair past `scrollAnim` — long enough that the scroll has actually settled before the next
|
||||
/// write, short enough to stay responsive.
|
||||
private var scrollSettle: TimeInterval { scrollAnim + 0.05 }
|
||||
|
||||
/// Drive the scroll toward the current cursor, one honoured write at a time. `.scrollPosition(id:)`
|
||||
/// DROPS a write that lands while a scroll is still animating, so we issue at most one at a time and
|
||||
/// re-target the LATEST cursor once it settles — coalescing a fast burst (hold OR quick flicks) and
|
||||
/// always converging on the final item, instead of getting stuck on the old card.
|
||||
///
|
||||
/// The settle is timed by a plain timer rather than `withAnimation`'s completion: `scrolledID` is a
|
||||
/// discrete id, not an animatable value, so `withAnimation` has no tracked animation to fire a
|
||||
/// reliable completion against (it can fire early — which is exactly what let quick flicks slip a
|
||||
/// write through mid-scroll and stick). `asyncAfter` always fires, so `isScrolling` can never latch.
|
||||
private func commitScroll() {
|
||||
guard !isScrolling, cursor >= 0, cursor < items.count else { return }
|
||||
let id = items[cursor].id
|
||||
guard scrolledID != id else { return }
|
||||
isScrolling = true
|
||||
withAnimation(.easeOut(duration: scrollAnim)) { scrolledID = id }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + scrollSettle) {
|
||||
MainActor.assumeIsolated {
|
||||
isScrolling = false
|
||||
commitScroll() // the cursor may have advanced while this scroll ran — chase it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
guard cursor >= 0, cursor < items.count else { return }
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
onActivate(items[cursor])
|
||||
}
|
||||
|
||||
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
||||
/// any other re-centers on it.
|
||||
private func tap(_ item: Item) {
|
||||
if let idx = index(of: item.id), idx == cursor {
|
||||
activate()
|
||||
} else if let idx = index(of: item.id) {
|
||||
cursor = idx
|
||||
lastNav = Date()
|
||||
haptics.move()
|
||||
selection = item.id
|
||||
commitScroll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection housekeeping
|
||||
|
||||
private func index(of id: Item.ID?) -> Int? {
|
||||
guard let id else { return nil }
|
||||
return items.firstIndex { $0.id == id }
|
||||
}
|
||||
|
||||
/// Keep `cursor`/`scrolledID`/`selection` consistent with `items`: seed on appear, and on a list
|
||||
/// change keep the same focused item when it survives, else clamp the cursor into range.
|
||||
private func reconcile() {
|
||||
guard !items.isEmpty else {
|
||||
cursor = 0
|
||||
if scrolledID != nil { scrolledID = nil }
|
||||
if selection != nil { selection = nil }
|
||||
return
|
||||
}
|
||||
if let sid = scrolledID, let idx = index(of: sid) {
|
||||
cursor = idx
|
||||
if selection != sid { selection = sid }
|
||||
} else {
|
||||
let idx = min(max(cursor, 0), items.count - 1)
|
||||
cursor = idx
|
||||
let id = items[idx].id
|
||||
scrolledID = id
|
||||
selection = id
|
||||
}
|
||||
}
|
||||
|
||||
private func boundaryBump(forward: Bool) {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
let recoil: CGFloat = forward ? -16 : 16
|
||||
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user