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:
1 parent
cf5f992e60
commit
db55b5eea0
5 files changed
+86
-11
No files matched your search
@@ -51,6 +51,8 @@ final class Ui {
|
||||
var nowPlayingOpen = false {
|
||||
didSet { overlays.level = nowPlayingOpen ? 1 : 0 }
|
||||
}
|
||||
/// True while an unfolded iPhone Duo shows Now Playing docked beside the library.
|
||||
private(set) var split = false
|
||||
let overlays: Overlays
|
||||
|
||||
init(overlays: Overlays) { self.overlays = overlays }
|
||||
@@ -84,7 +86,14 @@ final class Ui {
|
||||
if !tabs.contains(tab) { tab = tabs.first ?? .Settings }
|
||||
}
|
||||
|
||||
func openNowPlaying() { nowPlayingOpen = true }
|
||||
/// Docked Now Playing is already on screen, so there's nothing to open while split.
|
||||
func openNowPlaying() { if !split { nowPlayingOpen = true } }
|
||||
|
||||
func setSplit(_ on: Bool) {
|
||||
guard on != split else { return }
|
||||
split = on
|
||||
if on { nowPlayingOpen = false }
|
||||
}
|
||||
|
||||
/// Switches to the Library tab and pushes [r] there.
|
||||
func showInLibrary(_ r: Route) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,21 @@ private extension UIColor {
|
||||
|
||||
// ---- now playing ----------------------------------------------------------------------------
|
||||
|
||||
/// Full screen, Now Playing owns the window's color scheme. Docked beside the library it only
|
||||
/// sets its own pane's, since preferredColorScheme would repaint the library side too.
|
||||
private struct NpScheme: ViewModifier {
|
||||
var dark: Bool
|
||||
var docked: Bool
|
||||
func body(content: Content) -> some View {
|
||||
if docked { content.environment(\.colorScheme, dark ? .dark : .light) }
|
||||
else { content.preferredColorScheme(dark ? .dark : .light) }
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingScreen: View {
|
||||
/// Shown beside the library on an unfolded iPhone Duo rather than as a full-screen cover:
|
||||
/// no grabber, no drag to close, and its color scheme stays inside the pane.
|
||||
var docked = false
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var base
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -134,17 +148,17 @@ struct NowPlayingScreen: View {
|
||||
.background(c.bg.ignoresSafeArea())
|
||||
.overlay(alignment: .top) {
|
||||
// Stands in for the sheet's grabber: tap or drag down to close.
|
||||
Capsule().fill(c.label3).frame(width: 36, height: 5)
|
||||
if !docked { Capsule().fill(c.label3).frame(width: 36, height: 5)
|
||||
.frame(width: 120, height: 20).contentShape(Rectangle())
|
||||
.onTapGesture { dismiss() }
|
||||
.gesture(screenDrag)
|
||||
.gesture(screenDrag) }
|
||||
}
|
||||
.offset(y: drop)
|
||||
// On the art and the mixer: drag down anywhere to close, swipe up anywhere for the
|
||||
// queue. The queue and lyrics scroll, so there it's the song header or the grabber.
|
||||
.simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews)
|
||||
.environment(\.palette, c)
|
||||
.preferredColorScheme(c.dark ? .dark : .light)
|
||||
.modifier(NpScheme(dark: c.dark, docked: docked))
|
||||
.tint(c.label)
|
||||
.animation(.easeInOut(duration: 0.3), value: tint)
|
||||
.presentationBackground(.clear)
|
||||
@@ -259,12 +273,12 @@ struct NowPlayingScreen: View {
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onChanged { v in
|
||||
let dy = v.translation.height
|
||||
drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0
|
||||
drop = !docked && dy > 0 && dy > abs(v.translation.width) ? dy : 0
|
||||
}
|
||||
.onEnded { v in
|
||||
let dy = v.translation.height
|
||||
let vertical = abs(dy) > abs(v.translation.width)
|
||||
if vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
|
||||
if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
|
||||
if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user