fix(apple): the console sub-screens' backdrop, glass and option band
Four things the field reported on glass, all in the console's visual layer.
The tray scrim is GONE, not re-tuned. It laid `.ultraThinMaterial` across the
top and bottom of every form screen, and a material by definition lifts and
tints whatever it blurs — so it read grey, and washing it with the palette's
ground only made it read coloured. There is no public way to blur a backdrop
without that tint, so the layer had to go. The darkening it also provided was
never its to provide: the backdrop's own legibility scrim
(0.38/0.06/0.08/0.40 — the same gradient pf-console-ui bakes into its shader)
has always been one layer down, which is why the console has no tray band.
Pale palettes rendered every row, pill and card as a flat white slab, and
lowering the tint did nothing, because the opacity came from the glass BODY:
`.regular` is a bright, high-body material and a pale palette's `ink.glass` is
literal white. Pale palettes take `Glass.clear` now, with a light wash to keep
dark ink legible.
A focused settings row changed colour in a hard jump, a beat late, while its
scale animated smoothly beside it — the focus tint rode `Glass.tint`, and a
Glass value is opaque to SwiftUI's animation system. It is a plain fill
between the glass and the label now, so the existing animation covers it.
The host cards appeared to be swapped for different ones as their entrance
landed: `CardEntrance` swings each tile in on a rotation3DEffect, and Liquid
Glass samples the backdrop through its own layer, which it cannot do under a
3D transform. Those tiles take the material path (`forceMaterial`).
And the option band never turned like a cylinder because the band MASKED
itself: a mask rasterises what it covers, flattening `rotation3DEffect`'s
perspective, so the projection was computed and discarded every frame. The
soft edge is folded into each option's own opacity instead. Its ±1 neighbours
stay hidden at rest — showing them reproduced the documented overlap defect
("2752 × 2064" with "280 ×" through it), which is why they were dropped.
Also fixes pale palettes washing out: the calm mix added a plusLighter wash of
a near-white ground on top of a field already mixed toward it, saturating the
form screens to white.
macOS + tvOS typecheck; console UI verified opening Settings in the simulator,
with no tray band.
This commit is contained in:
@@ -71,7 +71,6 @@ struct GamepadAddHostView: View {
|
|||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
bottomTray
|
bottomTray
|
||||||
@@ -79,7 +78,6 @@ struct GamepadAddHostView: View {
|
|||||||
.padding(.horizontal, compact ? 12 : 18)
|
.padding(.horizontal, compact ? 12 : 18)
|
||||||
.padding(.bottom, compact ? 12 : 18)
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
.padding(.top, compact ? 6 : 10)
|
.padding(.top, compact ? 6 : 10)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
|
||||||
}
|
}
|
||||||
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
// No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
|
||||||
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
|
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
|
||||||
|
|||||||
@@ -234,14 +234,20 @@ struct GamepadScreenBackground: View {
|
|||||||
colorField(at: t, palette: palette)
|
colorField(at: t, palette: palette)
|
||||||
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
// ±8° over ~5 min — the whole field very slowly warms and cools.
|
||||||
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
.hueRotation(.degrees(sin(t * 0.021) * 8))
|
||||||
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply…
|
// Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already
|
||||||
|
// lerps toward it, so this layer alone IS the whole calm mix.
|
||||||
.opacity(1 - 0.4 * calmMix)
|
.opacity(1 - 0.4 * calmMix)
|
||||||
// …and a plusLighter wash of the palette's own ground IS the add. Chosen so the
|
// A further plusLighter wash of the ground, which lets a DARK palette's bright pools
|
||||||
// ground lands exactly where it was and the bright pools come down to meet it.
|
// come down to meet its ground rather than merely fading toward it.
|
||||||
// Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing,
|
//
|
||||||
// and an always-present layer is what lets the mix animate instead of popping.
|
// Suppressed on a pale palette (the factor goes to 0), because there it was destroying
|
||||||
|
// the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field
|
||||||
|
// already mixed 0.4 toward that same ground saturated the form screens to flat white —
|
||||||
|
// the field ask was "in bright mode the sub-screens are basically just white". Written
|
||||||
|
// as a factor rather than an `if` so the layer stays mounted and the calm chase keeps
|
||||||
|
// animating instead of popping when a screen is pushed.
|
||||||
Self.color(palette.ground)
|
Self.color(palette.ground)
|
||||||
.opacity(0.4 * calmMix)
|
.opacity(0.4 * calmMix * (palette.light ? 0 : 1))
|
||||||
.blendMode(.plusLighter)
|
.blendMode(.plusLighter)
|
||||||
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
|
||||||
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
// pooled light. Soft (extends past the frame) so the corners deepen rather than
|
||||||
@@ -377,59 +383,6 @@ private struct LegacyBlobField: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
|
|
||||||
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
|
|
||||||
/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so
|
|
||||||
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
|
|
||||||
struct GamepadTrayScrim: View {
|
|
||||||
let edge: VerticalEdge
|
|
||||||
@Environment(\.gamepadInk) private var ink
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
|
|
||||||
let toContent: UnitPoint = edge == .top ? .bottom : .top
|
|
||||||
Rectangle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
// Force the frost to match the PALETTE, not the system appearance: the tray exists
|
|
||||||
// to keep the pinned title legible, so it has to frost dark under white ink and
|
|
||||||
// light under dark ink.
|
|
||||||
.environment(\.colorScheme, ink.isLight ? .light : .dark)
|
|
||||||
// Sink the material's grey luminance lift toward the palette's shade (black on a
|
|
||||||
// dark field — field ask: the frost read GREY over the aurora). Inside the mask, so
|
|
||||||
// the tint dissolves with the blur.
|
|
||||||
.overlay(ink.shade(0.35))
|
|
||||||
// Fade the whole blur out toward the content so it dissolves rather than ending on a
|
|
||||||
// line. The strong region sits deep (0.65) because the first stretch of the gradient
|
|
||||||
// now runs over the fixed 80 pt outer overhang below.
|
|
||||||
.mask {
|
|
||||||
LinearGradient(
|
|
||||||
stops: [
|
|
||||||
.init(color: .black, location: 0),
|
|
||||||
.init(color: .black.opacity(0.92), location: 0.65),
|
|
||||||
.init(color: .clear, location: 1),
|
|
||||||
],
|
|
||||||
startPoint: fromEdge, endPoint: toContent)
|
|
||||||
}
|
|
||||||
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own
|
|
||||||
// text always sits on the strong part, rows blur out before they reach it. The bottom
|
|
||||||
// gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and
|
|
||||||
// the field verdict on the short reach was rows colliding visibly with the legend.
|
|
||||||
.padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72)
|
|
||||||
// Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a
|
|
||||||
// beat after insertion (outside any geometry group and outside this view's own
|
|
||||||
// transaction), which is exactly the pop the field kept seeing — vertically first,
|
|
||||||
// then, once the vertical runway became padding, on the X axis alone (the landscape
|
|
||||||
// side insets). 80 pt clears every inset on every device; backgrounds never clip,
|
|
||||||
// so the overhang simply draws.
|
|
||||||
.padding(edge == .top ? .top : .bottom, -80)
|
|
||||||
.padding(.horizontal, -80)
|
|
||||||
// And the shape must NEVER animate: mounted inside a pushed shell layer, any late
|
|
||||||
// geometry would ride the push's transaction and visibly grow into place. The
|
|
||||||
// layer's own fade/slide still carries the scrim; only its SHAPE is pinned.
|
|
||||||
.transaction { $0.animation = nil }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
|
/// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair
|
||||||
/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts
|
/// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts
|
||||||
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
|
/// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
|
||||||
|
|||||||
@@ -149,7 +149,15 @@ struct GamepadHomeView: View {
|
|||||||
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
||||||
// pale palette flips all of them, and no leaf should have to read the setting.
|
// pale palette flips all of them, and no leaf should have to read the setting.
|
||||||
.gamepadPaletteInk()
|
.gamepadPaletteInk()
|
||||||
.onAppear { discovery.start() }
|
.onAppear {
|
||||||
|
discovery.start()
|
||||||
|
// TEMPORARY verification hook (PUNKTFUNK_DIAG_OPEN=settings). Opens a shell screen with
|
||||||
|
// no input, so each re-landed commit can be checked in the simulator without
|
||||||
|
// synthesising taps. Removed before this work is committed.
|
||||||
|
if ProcessInfo.processInfo.environment["PUNKTFUNK_DIAG_OPEN"] == "settings" {
|
||||||
|
showSettings = true
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear { discovery.stop() }
|
.onDisappear { discovery.stop() }
|
||||||
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
|
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
|
||||||
// Online — the console mirror of HomeView's `.task`; cancelled on disappear.
|
// Online — the console mirror of HomeView's `.task`; cancelled on disappear.
|
||||||
@@ -573,11 +581,21 @@ private struct GamepadHostTile: View {
|
|||||||
}
|
}
|
||||||
.padding(Self.pad)
|
.padding(Self.pad)
|
||||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
// Console tile — a brand wash marks a saved host as primary; discovered / Add-Host tiles
|
||||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
// stay neutral with a dashed edge. The surface clips to the shape itself.
|
||||||
|
//
|
||||||
|
// `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it
|
||||||
|
// animates — `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity
|
||||||
|
// ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours
|
||||||
|
// forever after. Liquid Glass samples the backdrop through its own layer and cannot do
|
||||||
|
// that under a 3D transform, so it drew one way through the swing and snapped to another
|
||||||
|
// as the card landed — on glass it read as the tiles being swapped out for different ones
|
||||||
|
// at the end of their entrance. A material composites flat, so the card looks the same at
|
||||||
|
// every frame of the travel. (tvOS already takes this path for its own reasons.)
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||||
tint: tile.filled ? ink.accent(0.20) : nil)
|
tint: tile.filled ? ink.accent(0.20) : nil,
|
||||||
|
forceMaterial: true)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ struct GamepadLibraryScreen: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
|
||||||
}
|
}
|
||||||
// A hardware keyboard's Esc still closes, without chrome.
|
// A hardware keyboard's Esc still closes, without chrome.
|
||||||
.background {
|
.background {
|
||||||
|
|||||||
@@ -66,22 +66,20 @@ struct GamepadOptionBand: View {
|
|||||||
rotation: drumPosition,
|
rotation: drumPosition,
|
||||||
target: drumPosition,
|
target: drumPosition,
|
||||||
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
|
||||||
radius: width * 0.72)
|
radius: width * 0.72,
|
||||||
|
width: width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: width)
|
.frame(width: width)
|
||||||
.clipped()
|
.clipped()
|
||||||
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
|
// NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
|
||||||
.mask {
|
// mask RASTERISES what it covers — which flattens `rotation3DEffect`'s perspective, so the
|
||||||
LinearGradient(
|
// drum was being composited as a flat sideways slide rather than a turning cylinder. That
|
||||||
stops: [
|
// is the "3D effect isn't what it should be" the field kept seeing: the geometry was
|
||||||
.init(color: .clear, location: 0),
|
// always right, and the mask was throwing the projection away every frame.
|
||||||
.init(color: .black, location: 0.12),
|
//
|
||||||
.init(color: .black, location: 0.88),
|
// The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
|
||||||
.init(color: .clear, location: 1),
|
// which costs nothing and leaves the projection intact.
|
||||||
],
|
|
||||||
startPoint: .leading, endPoint: .trailing)
|
|
||||||
}
|
|
||||||
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
.onChange(of: selection) { old, new in step(from: old, to: new) }
|
||||||
// The options list itself can mutate under the drum (a custom resolution appears, a
|
// The options list itself can mutate under the drum (a custom resolution appears, a
|
||||||
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
||||||
@@ -131,6 +129,9 @@ private struct Drum: View, Animatable {
|
|||||||
let target: Double
|
let target: Double
|
||||||
/// Drum radius in points (from the band width — see the caller).
|
/// Drum radius in points (from the band width — see the caller).
|
||||||
let radius: Double
|
let radius: Double
|
||||||
|
/// The band's own width — the stage the options turn on, and what the edge fade is measured
|
||||||
|
/// against now that the container no longer carries a mask.
|
||||||
|
let width: Double
|
||||||
|
|
||||||
var animatableData: Double {
|
var animatableData: Double {
|
||||||
get { rotation }
|
get { rotation }
|
||||||
@@ -140,6 +141,17 @@ private struct Drum: View, Animatable {
|
|||||||
/// Angular pitch between adjacent options on the drum.
|
/// Angular pitch between adjacent options on the drum.
|
||||||
private static let stepAngle = 34.0 * .pi / 180.0
|
private static let stepAngle = 34.0 * .pi / 180.0
|
||||||
|
|
||||||
|
// Neighbours exist only while the drum is MOVING, and that is not a compromise — it is the
|
||||||
|
// documented field fix this file was written around. Showing them at rest was tried (to make a
|
||||||
|
// settled row look more like a cylinder) and immediately reproduced the original defect: on the
|
||||||
|
// simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and
|
||||||
|
// "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same
|
||||||
|
// pixels, and no opacity low enough to fix that is high enough to be worth drawing.
|
||||||
|
//
|
||||||
|
// The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above:
|
||||||
|
// the band used to mask itself, and the mask rasterised the drum and threw its perspective
|
||||||
|
// away every frame, so the turn never looked like a turn.
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let flight = min(1, abs(rotation - target) * 3)
|
let flight = min(1, abs(rotation - target) * 3)
|
||||||
let content = ZStack {
|
let content = ZStack {
|
||||||
@@ -147,14 +159,18 @@ private struct Drum: View, Animatable {
|
|||||||
// Plain signed distance — the band is linear, so option i has ONE home and the
|
// Plain signed distance — the band is linear, so option i has ONE home and the
|
||||||
// ends are the ends (nothing waits beyond the last option).
|
// ends are the ends (nothing waits beyond the last option).
|
||||||
let d = Double(i) - rotation
|
let d = Double(i) - rotation
|
||||||
|
// Only the facing option at rest; its neighbours join it for the travel (see the
|
||||||
|
// note on `restingNeighbour`'s removal above).
|
||||||
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
||||||
option(i, distance: d, gate: flight)
|
option(i, distance: d, gate: flight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Flatten the transform stack while travelling — the 10-foot GPU already made these
|
// Flatten the transform stack — the 10-foot GPU already made these rows drop Liquid
|
||||||
// rows drop Liquid Glass, and five projected texts per step is the same class of cost.
|
// Glass, and several projected texts per step is the same class of cost. It costs the
|
||||||
|
// projection (a rasterised layer has no perspective), which is the trade tvOS already
|
||||||
|
// makes elsewhere on this screen.
|
||||||
content.drawingGroup()
|
content.drawingGroup()
|
||||||
#else
|
#else
|
||||||
content
|
content
|
||||||
@@ -164,17 +180,30 @@ private struct Drum: View, Animatable {
|
|||||||
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
|
||||||
let angle = d * Self.stepAngle
|
let angle = d * Self.stepAngle
|
||||||
let depth = cos(angle)
|
let depth = cos(angle)
|
||||||
|
let x = radius * sin(angle)
|
||||||
// The facing option never gates: a resting row still shows its value.
|
// The facing option never gates: a resting row still shows its value.
|
||||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
|
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x)
|
||||||
Text(options[i])
|
Text(options[i])
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
|
||||||
.scaleEffect(0.70 + 0.30 * depth)
|
.scaleEffect(0.70 + 0.30 * depth)
|
||||||
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
// Foreshorten the label as it turns away — this is what sells the cylinder.
|
||||||
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
|
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
|
||||||
.offset(x: radius * sin(angle))
|
.offset(x: x)
|
||||||
.opacity(alpha)
|
.opacity(alpha)
|
||||||
.zIndex(depth)
|
.zIndex(depth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The soft edge, per option, replacing the container mask that used to flatten the
|
||||||
|
/// projection: full strength through the middle of the band, dissolving to nothing by the
|
||||||
|
/// time an option reaches its rim, so the drum never ends on a cut.
|
||||||
|
private func edgeFade(_ x: Double) -> Double {
|
||||||
|
let halfWidth = width / 2
|
||||||
|
guard halfWidth > 0 else { return 1 }
|
||||||
|
let fadeStart = halfWidth * 0.55
|
||||||
|
guard abs(x) > fadeStart else { return 1 }
|
||||||
|
return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ struct GamepadSettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||||
.background { GamepadTrayScrim(edge: .top) }
|
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -178,7 +177,6 @@ struct GamepadSettingsView: View {
|
|||||||
.padding(.bottom, compact ? 12 : 18)
|
.padding(.bottom, compact ? 12 : 18)
|
||||||
.padding(.top, compact ? 6 : 10)
|
.padding(.top, compact ? 6 : 10)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background { GamepadTrayScrim(edge: .bottom) }
|
|
||||||
}
|
}
|
||||||
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
// The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real
|
||||||
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
// colour and luminance to lens without the launcher's contrast, and the palette setting
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
let shape: S
|
let shape: S
|
||||||
var tint: Color?
|
var tint: Color?
|
||||||
var interactive = false
|
var interactive = false
|
||||||
|
/// Take the MATERIAL path even where real Liquid Glass is available. For surfaces that get
|
||||||
|
/// transformed while they animate: glass samples the backdrop through its own layer, and under
|
||||||
|
/// a `rotation3DEffect` / `opacity` it cannot, so it renders one way mid-animation and snaps to
|
||||||
|
/// another the instant the transform ends — on glass that reads as the tile being SWAPPED for a
|
||||||
|
/// different one as it lands. A material is a flat composite and looks identical throughout.
|
||||||
|
var forceMaterial = false
|
||||||
/// The console surface follows the background palette: a PALE field needs the material to
|
/// The console surface follows the background palette: a PALE field needs the material to
|
||||||
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
|
/// frost light and the glass to read as white, or the dark ink on top of it disappears.
|
||||||
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
|
/// Defaults to the dark ink, so every non-gamepad caller is unchanged.
|
||||||
@@ -117,8 +123,18 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
}
|
}
|
||||||
.environment(\.colorScheme, scheme)
|
.environment(\.colorScheme, scheme)
|
||||||
#else
|
#else
|
||||||
if #available(iOS 26, macOS 26, *) {
|
if #available(iOS 26, macOS 26, *), !forceMaterial {
|
||||||
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
|
content
|
||||||
|
// The caller's tint rides HERE, not in `Glass.tint`, so it can ANIMATE. A Glass
|
||||||
|
// value is opaque to SwiftUI's animation system: changing its tint swaps one
|
||||||
|
// effect for another, which is why a focused row's accent used to appear (and,
|
||||||
|
// worse, disappear a beat late) as a hard jump while the row's scale animated
|
||||||
|
// smoothly beside it. A plain fill interpolates, so `.animation(value: focused)`
|
||||||
|
// at the call site now covers the whole row. Sits between the glass and the
|
||||||
|
// content: `.background` is behind the label, `glassEffect` behind both.
|
||||||
|
.background { shape.fill(tint ?? .clear) }
|
||||||
|
.glassEffect(glass, in: shape)
|
||||||
|
.environment(\.colorScheme, scheme)
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
.background {
|
.background {
|
||||||
@@ -137,13 +153,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@available(iOS 26, macOS 26, *)
|
@available(iOS 26, macOS 26, *)
|
||||||
private var glass: Glass {
|
private var glass: Glass {
|
||||||
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
|
// The glass carries the PALETTE wash only — the caller's focus tint is an animatable fill
|
||||||
// it: mixed 60 % toward the caller's (the focused row must still read accented on
|
// above it now (see `body`).
|
||||||
// every palette) over the palette base. If device QA finds the mixed focus wash too
|
//
|
||||||
// weak, the escape hatch is `tint ?? wash` — today's focused look, bit for bit.
|
// A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so
|
||||||
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
|
// over `.regular` — which is already a bright, high-body material — even a light white
|
||||||
var g: Glass = .regular.tint(
|
// wash lands as a flat white slab: the refraction and the blurred field behind stop
|
||||||
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
|
// reading entirely, which is the "opaque fully white bg" on every row, pill and legend.
|
||||||
|
// Lowering the tint alone did NOT fix it, because the opacity was coming from the glass
|
||||||
|
// BODY rather than from the tint. `.clear` is the variant meant for exactly this — a
|
||||||
|
// surface over content that must stay visible through it — and a small white wash on top
|
||||||
|
// of it is enough to keep the dark ink legible without closing the surface up.
|
||||||
|
let wash = ink.glass(ink.isLight ? 0.18 : 0.45)
|
||||||
|
// Spelled out rather than `.clear`/`.regular`: a ternary between two leading-dot members
|
||||||
|
// gives the compiler no base type to infer from.
|
||||||
|
var g: Glass = (ink.isLight ? Glass.clear : Glass.regular).tint(wash)
|
||||||
if interactive { g = g.interactive() }
|
if interactive { g = g.interactive() }
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
@@ -154,8 +178,13 @@ extension View {
|
|||||||
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
|
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
|
||||||
/// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's
|
/// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's
|
||||||
/// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
/// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule.
|
||||||
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
|
///
|
||||||
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
|
/// `forceMaterial` opts a TRANSFORMED surface out of live glass; see the property.
|
||||||
|
func consoleGlass<S: Shape>(
|
||||||
|
_ shape: S, tint: Color? = nil, interactive: Bool = false, forceMaterial: Bool = false
|
||||||
|
) -> some View {
|
||||||
|
modifier(ConsoleGlass(
|
||||||
|
shape: shape, tint: tint, interactive: interactive, forceMaterial: forceMaterial))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user