iOS: iPhone Duo split layout at the hinge, 1.0 RC 3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-09 07:57:58 -08:00
1 parent cf5f992e60
commit db55b5eea0
5 files changed
+86 -11

No files matched your search

+54 -3
View File
@@ -1,6 +1,7 @@
import SwiftUI
/// The tab bar, a navigation stack per tab, the mini player and the Now Playing sheet.
/// On an unfolded iPhone Duo the library sits on one side of the hinge and Now Playing on the other.
struct RootView: View {
@State private var ui = Ui(overlays: Overlays())
private var g: AppGraph { .shared }
@@ -8,6 +9,7 @@ struct RootView: View {
var body: some View {
let s = g.settings
let p = Palette.of(s.theme, s.accent)
DuoSplit(onSplit: { ui.setSplit($0) }) {
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
NavigationStack(path: ui.path(t)) {
@@ -20,6 +22,10 @@ struct RootView: View {
}
}
.modifier(GlassMiniPlayerHost())
} docked: {
NowPlayingScreen(docked: true)
}
.background(p.bg.ignoresSafeArea())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
.fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen()
@@ -91,11 +97,12 @@ struct RootView: View {
private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }()
private struct GlassMiniPlayerHost: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
if #available(iOS 26.1, *) {
content
.tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() }
.tabViewBottomAccessory(isEnabled: g.playback.current != nil && !ui.split) { MiniPlayer() }
.tabBarMinimizeBehavior(.onScrollDown)
} else {
content
@@ -105,9 +112,10 @@ private struct GlassMiniPlayerHost: ViewModifier {
/// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
private struct FloatingMiniPlayer: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
if glassAccessory {
if glassAccessory || ui.split {
content
} else {
content
@@ -120,10 +128,11 @@ private struct FloatingMiniPlayer: ViewModifier {
}
private struct MiniPlayerRoom: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) }
if !glassAccessory && !ui.split && g.playback.current != nil { Color.clear.frame(height: 66) }
}
}
}
@@ -131,3 +140,45 @@ private struct MiniPlayerRoom: ViewModifier {
private extension View {
func miniPlayerRoom() -> some View { modifier(MiniPlayerRoom()) }
}
/// Splits the screen at the iPhone Duo's hinge (the system's division region): [library] on the
/// leading or top side, [docked] on the other. Anywhere without an active hinge it's just [library].
/// One layout for both states keeps the library's tab and scroll state across fold and unfold.
private struct DuoSplit<Library: View, Docked: View>: View {
var onSplit: (Bool) -> Void
@ViewBuilder var library: Library
@ViewBuilder var docked: Docked
var body: some View {
GeometryReader { geo in
let cut = Self.cut(geo)
let layout = cut?.vertical == false ? AnyLayout(VStackLayout(spacing: cut?.gap ?? 0)) : AnyLayout(HStackLayout(spacing: cut?.gap ?? 0))
layout {
library
.frame(width: cut.map { $0.vertical ? $0.first : geo.size.width } ?? geo.size.width,
height: cut.map { $0.vertical ? geo.size.height : $0.first } ?? geo.size.height)
if let cut {
docked
.frame(width: cut.vertical ? cut.second : geo.size.width,
height: cut.vertical ? geo.size.height : cut.second)
}
}
.onChange(of: cut != nil, initial: true) { _, on in onSplit(on) }
}
}
/// Where the hinge cuts the view: sizes of the two sides and the gap between them.
private struct Cut { var vertical: Bool; var first: CGFloat; var gap: CGFloat; var second: CGFloat }
private static func cut(_ geo: GeometryProxy) -> Cut? {
guard #available(iOS 27.1, *),
let r = geo.reservedRegions(kind: .division).first(where: \.isActive)?.frame else { return nil }
let size = geo.size
let vertical = r.height >= r.width
let c = vertical
? Cut(vertical: true, first: r.minX, gap: r.width, second: size.width - r.maxX)
: Cut(vertical: false, first: r.minY, gap: r.height, second: size.height - r.maxY)
// A region that doesn't leave usable room on both sides isn't a hinge worth splitting at.
return c.first > 200 && c.second > 200 ? c : nil
}
}