iOS: Duo unfolded layout with right-edge tab rail, stems+queue deck and swap sides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-09 17:02:08 -08:00
1 parent f4af7b6cf8
commit 08f7af9421
4 files changed
+138 -14

No files matched your search

+84 -10
View File
@@ -2,15 +2,18 @@ import SwiftUI
import UIKit
/// 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.
/// On an unfolded iPhone Duo Now Playing takes one side of the hinge, the stems and queue (or a tab)
/// the other, and the tabs move to a rail on the right edge beside the status bar.
struct RootView: View {
@State private var ui = Ui(overlays: Overlays())
/// Unfolded iPhone Duo: Now Playing on the left of the hinge instead of the right.
@AppStorage("duoSwap") private var duoSwap = false
private var g: AppGraph { .shared }
var body: some View {
let s = g.settings
let p = Palette.of(s.theme, s.accent)
DuoSplit(onSplit: { ui.setSplit($0) }) {
DuoSplit(swap: duoSwap, showDeck: ui.deck, 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)) {
@@ -23,8 +26,12 @@ struct RootView: View {
}
}
.modifier(GlassMiniPlayerHost())
} deck: {
NowPlayingDeck()
} docked: {
NowPlayingScreen(docked: true)
} rail: {
DuoRail(swap: $duoSwap)
}
.background(p.bg.ignoresSafeArea())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
@@ -135,6 +142,8 @@ private struct MiniPlayerRoom: ViewModifier {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !glassAccessory && !ui.split && g.playback.current != nil { Color.clear.frame(height: 66) }
}
// Split, the rail stands in for the tab bar.
.toolbar(ui.split ? .hidden : .automatic, for: .tabBar)
}
}
@@ -142,28 +151,55 @@ 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 {
/// Splits the screen at the iPhone Duo's hinge (the system's division region). [library] (with
/// [deck] over it while [showDeck]) goes on one side and [docked] on the other, left and right
/// trading places with [swap]; [rail] runs down the right edge in the status bar's strip.
/// Anywhere without a hinge it's just [library]. The library stays put in the view tree, so its
/// tab and scroll state survive folding, unfolding and swapping.
private struct DuoSplit<Library: View, Deck: View, Docked: View, Rail: View>: View {
var swap: Bool
var showDeck: Bool
var onSplit: (Bool) -> Void
@ViewBuilder var library: Library
@ViewBuilder var deck: Deck
@ViewBuilder var docked: Docked
@ViewBuilder var rail: Rail
private static var railWidth: CGFloat { 64 }
var body: some View {
GeometryReader { geo in
let cut = Self.cut(geo)
let inset = geo.safeAreaInsets.trailing
// The rail lives in the status bar's strip; if that's too thin it borrows from the right pane.
let steal = cut == nil ? 0 : max(0, Self.railWidth - inset)
let flip = swap && cut?.vertical == true
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)
ZStack {
library
if cut != nil && showDeck { deck.transition(.opacity) }
}
.environment(\.layoutDirection, .leftToRight)
.frame(width: cut.map { $0.vertical ? (flip ? $0.second - steal : $0.first) : geo.size.width - steal } ?? 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,
.environment(\.layoutDirection, .leftToRight)
.frame(width: cut.vertical ? (flip ? cut.first : cut.second - steal) : geo.size.width - steal,
height: cut.vertical ? geo.size.height : cut.second)
}
}
// Right-to-left lays the same two children out mirrored, so swapping doesn't rebuild either.
.environment(\.layoutDirection, flip ? .rightToLeft : .leftToRight)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .topTrailing) {
if cut != nil {
rail.frame(width: max(inset, Self.railWidth), height: geo.size.height).offset(x: inset)
}
}
.animation(.spring(duration: 0.4, bounce: 0.1), value: flip)
.animation(.easeInOut(duration: 0.2), value: showDeck)
.onChange(of: cut != nil, initial: true) { _, on in onSplit(on) }
}
}
@@ -193,3 +229,41 @@ private struct DuoSplit<Library: View, Docked: View>: View {
return c.first > 200 && c.second > 200 ? c : nil
}
}
/// The tab bar's stand-in on an unfolded iPhone Duo: Now Playing's stems and queue, the tabs, and
/// a button that swaps which side of the hinge Now Playing sits on.
private struct DuoRail: View {
@Binding var swap: Bool
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
var body: some View {
VStack(spacing: 4) {
Spacer(minLength: 48)
item("Playing", "waveform", on: ui.deck) { ui.showDeck() }
ForEach(ui.tabs, id: \.self) { t in
item(t.rawValue, t.icon, on: !ui.deck && ui.tab == t) { ui.selectTab(t) }
}
Capsule().fill(p.label3.opacity(0.4)).frame(width: 24, height: 2).padding(.vertical, 8)
item("Swap", "arrow.left.arrow.right", on: false) { swap.toggle() }
Spacer(minLength: 48)
}
.frame(maxWidth: .infinity)
}
private func item(_ title: String, _ icon: String, on: Bool, _ action: @escaping () -> Void) -> some View {
Button { withAnimation(.snappy(duration: 0.25)) { action() } } label: {
VStack(spacing: 3) {
Image(systemName: icon).font(.system(size: 19, weight: on ? .semibold : .regular)).frame(height: 24)
Text(title).font(.system(size: 10, weight: .medium)).lineLimit(1).minimumScaleFactor(0.7)
}
.foregroundStyle(on ? p.accent : p.label2)
.frame(width: 56, height: 52)
.background(on ? p.accent.opacity(0.14) : .clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(PressFade())
.accessibilityLabel(title)
.accessibilityAddTraits(on ? .isSelected : [])
}
}