commit 397967d5c19b6ac75408a8bfa6ef6ebd71abd602
parent 2ed55135d4790ca31855543650c95b3829a9dc7b
Author: Michael Camilleri <[email protected]>
Date: Sat, 20 Jun 2026 14:16:27 +0900
Refit the scoreboard into the Puzzle Header at larger type sizes
On iPhone a player using a larger Dynamic Type size could not see the
scoreboard: it no longer fit between the grid and the top toolbar and
spilled up into the toolbar. The horizontal scoreboard pinned its own
fixed height — one that itself scaled up with the type size — inside a
header band that deliberately shrank as type grows, so the two moved in
opposite directions until the chips overflowed.
This commit lets the scoreboard fill whatever height the header yields
rather than imposing its own, so it can never exceed the band. The
'Players' heading and the score chips now sit in a single centred group
that hugs its content and grows outward from the middle as players
arrive, while the chip rows wrap leading to trailing within it —
FlowLayout gained a leading alignment and now reports its content width
so the group can be centred. A bottom scroll anchor rests the common
player strip against the bottom of the band, matching the title and
credits pages, and scrolls only once enough players overflow. The
scoreboard is the least important text in the band, so its type scaling
is capped a few steps below the largest size.
Finally, the 'Players' button on iOS no longer uses the system
glassEffect, whose ambient shadow clipped untidily against the band
edge. On the flat white header it imitates glass instead — a translucent
fill with a hairline rim, bright along the top and fading dark along the
bottom — which reads as a lit glass edge without casting a shadow. The
iPad side panel keeps real glass via nudgeCapsule, where the shadow has
room to sit.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 67 insertions(+), 32 deletions(-)
diff --git a/Crossmate/Views/Components/Layouts.swift b/Crossmate/Views/Components/Layouts.swift
@@ -1,10 +1,16 @@
import SwiftUI
/// Lays subviews left-to-right, wrapping onto a new line when the next
-/// subview would overflow the proposed width. Reports the wrapped
-/// height for that width so a surrounding `ViewThatFits` can choose
-/// between a centred (fits) and a scrolling (overflows) presentation.
+/// subview would overflow the proposed width. Hugs its content width (the
+/// widest row) so a surrounding view can size to it and centre the block;
+/// rows are aligned per `alignment` — centred by default, or leading.
struct FlowLayout: Layout {
+ enum Alignment {
+ case leading
+ case center
+ }
+
+ var alignment: Alignment = .center
var spacing: CGFloat = 18
var lineSpacing: CGFloat = 8
@@ -45,7 +51,7 @@ struct FlowLayout: Layout {
let height = rows.reduce(0) { $0 + $1.height }
+ lineSpacing * CGFloat(max(0, rows.count - 1))
let widest = rows.map(\.width).max() ?? 0
- return CGSize(width: proposal.width ?? widest, height: height)
+ return CGSize(width: widest, height: height)
}
func placeSubviews(
@@ -57,9 +63,13 @@ struct FlowLayout: Layout {
let rows = rows(subviews, maxWidth: bounds.width)
var y = bounds.minY
for row in rows {
- // Centre each row within the available width so a short
- // strip (e.g. a single player) sits in the middle.
- var x = bounds.minX + max(0, (bounds.width - row.width) / 2)
+ // Align each row within the allocated width. The block hugs its
+ // widest row, so leading rows start flush at the edge and only a
+ // wider sibling shifts a short row when centred.
+ var x = bounds.minX
+ if alignment == .center {
+ x += max(0, (bounds.width - row.width) / 2)
+ }
for index in row.indices {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(
diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift
@@ -10,7 +10,6 @@ struct PuzzleScoreboard: View {
/// if a nudge is allowed right now.
var nudgeReadyAt: () -> Date? = { nil }
@Environment(PlayerPreferences.self) private var preferences
- @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56
/// Briefly swaps the nudge capsule for a "Nudge Sent" confirmation.
@State private var showNudgeSent = false
/// The send-cooldown deadline that dims the button, stamped synchronously on
@@ -236,31 +235,40 @@ struct PuzzleScoreboard: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
- private var chipFlow: some View {
- FlowLayout(spacing: 18, lineSpacing: 8) {
- ForEach(scores) { score in
- scoreChip(score)
- }
- }
- .padding(.horizontal, 18)
- .padding(.vertical, 4)
- }
-
private var horizontalBody: some View {
- // A titled "Players" section mirroring the iPad side panel
- // (verticalBody). It sizes to its content and sits top-anchored
- // in a ScrollView, so it reads as a deliberate header section
- // rather than a stray chip, and scrolls when there are enough
- // players to overflow the band — no centring tricks required.
+ // The heading/nudge capsule pins to the leading edge and the score
+ // chips flow after it, wrapping leading-to-trailing onto new lines.
+ // The whole [heading | chips] group hugs its content and centres in
+ // the band, so it grows outward from the middle as players arrive,
+ // while the rows inside stay leading-aligned. A vertical ScrollView
+ // absorbs the overflow when enough players wrap past the slim band,
+ // rather than letting it spill into the toolbar above. Filling the
+ // band's height (rather than pinning a fixed height) keeps the strip
+ // inside whatever space the header yields as Dynamic Type grows.
+ //
+ // `.bottom` scroll anchor rests a short strip (the common 2–3 player
+ // case) against the bottom of the band, matching the title/credits
+ // pages, yet still scrolls up from the bottom once the rows overflow.
ScrollView(.vertical, showsIndicators: false) {
- VStack(spacing: 6) {
+ HStack(alignment: .center, spacing: 18) {
playersHeading
- chipFlow
+ FlowLayout(alignment: .leading, spacing: 18, lineSpacing: 8) {
+ ForEach(scores) { score in
+ scoreChip(score)
+ }
+ }
}
.frame(maxWidth: .infinity)
- .padding(.vertical, 3)
+ .padding(.horizontal, 18)
+ .padding(.vertical, 4)
}
- .frame(height: horizontalHeaderHeight)
+ .frame(maxHeight: .infinity)
+ .defaultScrollAnchor(.bottom)
+ // The scoreboard is the least important text in the band, so cap its
+ // type scaling a few steps below the top: past xLarge it stops growing
+ // rather than forcing the chips to wrap further and the band to keep
+ // eating into the grid.
+ .dynamicTypeSize(...DynamicTypeSize.xLarge)
}
/// The vertical (side-panel) heading. With more horizontal room to spare,
@@ -387,11 +395,28 @@ struct PuzzleScoreboard: View {
.font(.footnote.weight(.semibold))
.padding(.horizontal, 12)
.padding(.vertical, 4)
- .nudgeGlass(isLabeled: true)
- // The horizontal header sits on a flat white surface where clear glass
- // nearly vanishes, so a faint shadow lifts the capsule enough to read
- // as a button without tinting away the glass look.
- .shadow(color: .black.opacity(0.18), radius: 3, y: 1)
+ // Real `glassEffect` casts its own ambient shadow that clips untidily
+ // against the band edge on this flat white header, so the capsule
+ // imitates glass rather than using the system material: a translucent
+ // white fill brightens the capsule off the header and the hairline rim
+ // below supplies the lit glass edge, with no shadow. (The iPad side
+ // panel keeps real glass via `nudgeCapsule`/`nudgeGlass`, where the
+ // shadow has room to sit.)
+ .background(Color.white.opacity(0.6), in: Capsule())
+ // A rim bright along the top, fading dark along the bottom, reads as a
+ // curved glass edge — defining the button without flattening it into a
+ // plain outline.
+ .overlay {
+ Capsule()
+ .strokeBorder(
+ LinearGradient(
+ colors: [.white.opacity(0.4), .black.opacity(0.14)],
+ startPoint: .top,
+ endPoint: .bottom
+ ),
+ lineWidth: 0.75
+ )
+ }
}
private func scoreChip(_ score: Score) -> some View {