Split the corner layout on the hinge when partially open

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.
This commit is contained in:
Arkadiusz Fal
2026-10-09 23:19:39 +02:00
parent 4d690d6b71
commit fe109394df
4 changed files with 55 additions and 17 deletions

View File

@@ -28,6 +28,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
let onChannelTap: (() -> Void)?
var onFullscreen: (() -> Void)? = nil
/// Foldable hinge partially open (iPhone Duo, book posture): the side
/// column (grabber included) takes exactly the trailing half so the
/// split lands on the hinge. Not resizable then — only overdrag-hide.
var isHingeSplit = false
/// Seed the panel visibility BEFORE the first frame — applying the
/// preset's default visibility via onAppear/onChange animates the panels
@@ -44,8 +48,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
_ height: CGFloat
) -> PlayerContent,
onChannelTap: (() -> Void)?,
onFullscreen: (() -> Void)? = nil
onFullscreen: (() -> Void)? = nil,
isHingeSplit: Bool = false
) {
self.isHingeSplit = isHingeSplit
self.playerControlsLayout = playerControlsLayout
self.playerContent = playerContent
self.onChannelTap = onChannelTap
@@ -179,9 +185,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
}
private func clampColumnWidth(_ width: CGFloat, availableWidth: CGFloat) -> CGFloat {
// Up to half the window, grabber included
let maxWidth = max(
Self.minColumnWidth,
min(Self.maxColumnWidthCap, availableWidth * 0.45, availableWidth - Self.minVideoColumnWidth)
min(Self.maxColumnWidthCap, availableWidth / 2 - Self.grabberWidth, availableWidth - Self.minVideoColumnWidth)
)
return min(max(width, Self.minColumnWidth), maxWidth)
}
@@ -227,10 +234,12 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
// away on its own while the panel stays nominally visible.
let showsAnyRegion = showsSideColumn || showsBelowVideo
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
// Grabber sits on the column's leading edge, inside the column
// background — narrow strip matching the panel drag handle look.
let grabberWidth = Self.grabberWidth
// Hinge split ignores (and never overwrites) the saved width.
let hingeColumnWidth = max(Self.minColumnWidth, width / 2 - grabberWidth)
let effectiveColumnWidth = isHingeSplit ? hingeColumnWidth : clampColumnWidth(columnWidth, availableWidth: width)
let totalColumnWidth = effectiveColumnWidth + grabberWidth
// Panels never move. Both sit at their home positions (column on
@@ -315,9 +324,9 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
if hasSideColumn, let video {
HStack(spacing: 0) {
PanelResizeGrabber(
panelWidth: $columnWidth,
minWidth: Self.minColumnWidth,
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
panelWidth: isHingeSplit ? .constant(hingeColumnWidth) : $columnWidth,
minWidth: isHingeSplit ? hingeColumnWidth : Self.minColumnWidth,
maxWidth: isHingeSplit ? hingeColumnWidth : clampColumnWidth(.infinity, availableWidth: width),
panelSide: .right,
isPinned: true,
isDragActive: $isGrabberActive,

View File

@@ -1364,10 +1364,20 @@ extension ExpandedPlayerSheet {
#if os(iOS)
self.toggleFullscreen()
#endif
}
},
isHingeSplit: isHingeSplitActive
)
}
/// The corner layout splits on the hinge while it's partially open.
private var isHingeSplitActive: Bool {
#if os(iOS)
isTabletopHinge
#else
false
#endif
}
// MARK: - Widescreen Player Area
/// Player area for widescreen layout with panel support.

View File

@@ -100,7 +100,9 @@ struct ExpandedPlayerSheet: View {
/// Foldable hinge partially open (iPhone Duo laptop position): the
/// fullscreen portrait layout keeps the video on the upper half and
/// moves every control to the lower half.
@State var isTabletopHinge = false
/// Seeded from the last hinge report so a re-presented player starts in
/// the right layout instead of animating into it.
@State var isTabletopHinge = HingeLayoutObserver.lastKnownTabletop
#endif
#if os(iOS) || os(macOS)

View File

@@ -18,6 +18,28 @@ struct HingeLayoutObserver: ViewModifier {
/// 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
@@ -29,10 +51,7 @@ struct HingeLayoutObserver: ViewModifier {
// Mirror the real hinge state: no programmatic rotation (and no
// orientation lock) while the hinge is open.
OrientationManager.shared.canRotateProgrammatically = !isInterior
guard isInterior != isTabletop else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isTabletop = isInterior
}
apply(isInterior)
}
} else if #available(iOS 27.1, *) {
content.onHingeChange { _, context in
@@ -43,11 +62,9 @@ struct HingeLayoutObserver: ViewModifier {
// programmatic rotation, so the player must not offer it.
OrientationManager.shared.canRotateProgrammatically = status == .closed
}
let tabletop = context.hinge?.status == .partiallyOpen
guard tabletop != isTabletop else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isTabletop = tabletop
}
// nil when leaving the hierarchy: keep the last state then
guard let status = context.hinge?.status else { return }
apply(status == .partiallyOpen)
}
} else {
content