mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
On a half-open foldable the side column takes exactly the trailing half, so the split lands on the hinge. Fully open, the column can now be resized up to half the width. A re-presented player seeds the hinge state from the last report so it doesn't animate into the split.
113 lines
4.3 KiB
Swift
113 lines
4.3 KiB
Swift
//
|
||
// HingeLayoutObserver.swift
|
||
// Yattee
|
||
//
|
||
// Observes the foldable device hinge (iPhone Duo) and reports whether the
|
||
// player should use the "tabletop" layout: hinge partially open, device
|
||
// resting like a laptop, video on the upper half and controls on the lower.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
#if os(iOS)
|
||
|
||
struct HingeLayoutObserver: ViewModifier {
|
||
@Binding var isTabletop: Bool
|
||
|
||
/// Debug override: launch with `YATTEE_FORCE_TABLETOP=1` to force the
|
||
/// tabletop layout on simulators without a hinge control.
|
||
static let forceTabletop = ProcessInfo.processInfo.environment["YATTEE_FORCE_TABLETOP"] == "1"
|
||
|
||
/// Last reported tabletop state, outliving the player sheet: re-presenting
|
||
/// the player seeds from it so the layout doesn't animate in from the
|
||
/// unfolded state.
|
||
@MainActor static var lastKnownTabletop = false
|
||
|
||
/// The first report after appearing corrects the seed without animation.
|
||
@State private var hasReceivedReport = false
|
||
|
||
private func apply(_ tabletop: Bool) {
|
||
Self.lastKnownTabletop = tabletop
|
||
let animated = hasReceivedReport
|
||
hasReceivedReport = true
|
||
guard tabletop != isTabletop else { return }
|
||
if animated {
|
||
withAnimation(.easeInOut(duration: 0.3)) { isTabletop = tabletop }
|
||
} else {
|
||
var transaction = Transaction()
|
||
transaction.disablesAnimations = true
|
||
withTransaction(transaction) { isTabletop = tabletop }
|
||
}
|
||
}
|
||
|
||
func body(content: Content) -> some View {
|
||
if Self.forceTabletop {
|
||
// The override stands in for a half-open hinge, which only exists
|
||
// on the interior display: folded (exterior, 466×678) keeps the
|
||
// regular layout, like the real `.closed` state would.
|
||
content.onGeometryChange(for: Bool.self) { proxy in
|
||
min(proxy.size.width, proxy.size.height) >= ExpandedPlayerSheet.minHeightForCornerLayout
|
||
} action: { isInterior in
|
||
// Mirror the real hinge state: no programmatic rotation (and no
|
||
// orientation lock) while the hinge is open.
|
||
OrientationManager.shared.canRotateProgrammatically = !isInterior
|
||
apply(isInterior)
|
||
}
|
||
} else if #available(iOS 27.1, *) {
|
||
content.onHingeChange { _, context in
|
||
// `hinge` is nil on devices without a hinge and when the view
|
||
// leaves a hierarchy that provides hinge updates.
|
||
if let status = context.hinge?.status {
|
||
// Interior display (hinge open): the system refuses
|
||
// programmatic rotation, so the player must not offer it.
|
||
OrientationManager.shared.canRotateProgrammatically = status == .closed
|
||
}
|
||
// nil when leaving the hierarchy: keep the last state then
|
||
guard let status = context.hinge?.status else { return }
|
||
apply(status == .partiallyOpen)
|
||
}
|
||
} else {
|
||
content
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Reports whether the device has a hinge at all (foldable iPhone), for UI
|
||
/// that only applies to the interior display, such as the corner layout option.
|
||
private struct HingePresenceObserver: ViewModifier {
|
||
@Binding var hasHinge: Bool
|
||
|
||
func body(content: Content) -> some View {
|
||
if #available(iOS 27.1, *) {
|
||
content.onHingeChange { _, context in
|
||
// nil also when leaving a hinge-providing hierarchy; keep the last value then
|
||
guard context.hinge != nil else { return }
|
||
hasHinge = true
|
||
}
|
||
} else {
|
||
content
|
||
}
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
/// Tracks the device hinge and writes whether the tabletop player layout
|
||
/// should be active. No-op before iOS 27.1 and on devices without a hinge.
|
||
func observeHingeLayout(isTabletop: Binding<Bool>) -> some View {
|
||
modifier(HingeLayoutObserver(isTabletop: isTabletop))
|
||
}
|
||
|
||
/// Writes whether the device has a hinge. No-op before iOS 27.1.
|
||
func observeHinge(hasHinge: Binding<Bool>) -> some View {
|
||
modifier(HingePresenceObserver(hasHinge: hasHinge))
|
||
}
|
||
}
|
||
|
||
#else
|
||
|
||
extension View {
|
||
func observeHinge(hasHinge: Binding<Bool>) -> some View { self }
|
||
}
|
||
|
||
#endif
|