commit a0bc1c7f6afbcb567538aa709c933442b3b70065
parent 78278785c9acfd1ff3889f04946cece7a8fd3162
Author: Michael Camilleri <[email protected]>
Date: Tue, 19 May 2026 09:37:42 +0900
Wrap and centre the header scoreboard chips
The scoreboard page in the swipeable Puzzle Grid header (the .horizontal
PuzzleScoreboard, shown only on iPhone) laid its player chips out as a single
HStack inside a horizontal-scroll ViewThatFits. Inside the fixed 80pt header
band that strip pinned to the top rather than centring, and extra players ran
off the side instead of wrapping.
Replace the single row with a FlowLayout built on the Layout protocol —
matching the existing WeightedVStack/KeyboardRow approach and avoiding
GeometryReader. It lays chips left-to-right, wraps onto new rows when the next
chip would overflow the available width, and centres each row within that
width, so a lone player sits in the middle. horizontalBody now uses
ViewThatFits(in: .vertical): while the wrapped block fits the band it is
centred by the page frame, and once it overflows it scrolls vertically. The
vertical scroll axis is orthogonal to the horizontal TabView paging, so swiping
between title/scoreboard/credits still works. The iPad side-panel .vertical
scoreboard layout is untouched.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
1 file changed, 86 insertions(+), 10 deletions(-)
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -641,22 +641,24 @@ private struct PuzzleScoreboard: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
- private var scoreChips: some View {
- HStack(spacing: 18) {
+ 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 {
- // Centre the chips when they fit; fall back to a leading scroll
- // strip only once there are too many players to fit the width.
- ViewThatFits(in: .horizontal) {
- scoreChips
- ScrollView(.horizontal, showsIndicators: false) {
- scoreChips
+ // Chips flow left-to-right and wrap onto new lines. While the
+ // wrapped block fits the header band it is centred by the page
+ // frame; once it overflows it scrolls vertically instead.
+ ViewThatFits(in: .vertical) {
+ chipFlow
+ ScrollView(.vertical, showsIndicators: false) {
+ chipFlow
}
}
.frame(maxWidth: .infinity)
@@ -1152,8 +1154,8 @@ private struct PuzzleCredits: View {
var body: some View {
VStack(spacing: 2) {
- if let author {
- Text(author)
+ if let author, !author.isEmpty {
+ Text("By \(author)")
.font(.subheadline)
.lineLimit(2)
}
@@ -1404,6 +1406,80 @@ private struct AccessRevokedBanner: View {
}
}
+/// 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.
+private struct FlowLayout: Layout {
+ var spacing: CGFloat = 18
+ var lineSpacing: CGFloat = 8
+
+ private struct Row {
+ var indices: [Int] = []
+ var width: CGFloat = 0
+ var height: CGFloat = 0
+ }
+
+ private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [Row] {
+ var rows: [Row] = []
+ var current = Row()
+ for index in subviews.indices {
+ let size = subviews[index].sizeThatFits(.unspecified)
+ let needed = current.indices.isEmpty
+ ? size.width
+ : current.width + spacing + size.width
+ if !current.indices.isEmpty, needed > maxWidth {
+ rows.append(current)
+ current = Row(indices: [index], width: size.width, height: size.height)
+ } else {
+ if !current.indices.isEmpty { current.width += spacing }
+ current.indices.append(index)
+ current.width += size.width
+ current.height = max(current.height, size.height)
+ }
+ }
+ if !current.indices.isEmpty { rows.append(current) }
+ return rows
+ }
+
+ func sizeThatFits(
+ proposal: ProposedViewSize,
+ subviews: Subviews,
+ cache: inout ()
+ ) -> CGSize {
+ let rows = rows(subviews, maxWidth: proposal.width ?? .infinity)
+ 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)
+ }
+
+ func placeSubviews(
+ in bounds: CGRect,
+ proposal: ProposedViewSize,
+ subviews: Subviews,
+ cache: inout ()
+ ) {
+ 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)
+ for index in row.indices {
+ let size = subviews[index].sizeThatFits(.unspecified)
+ subviews[index].place(
+ at: CGPoint(x: x, y: y),
+ anchor: .topLeading,
+ proposal: ProposedViewSize(size)
+ )
+ x += size.width + spacing
+ }
+ y += row.height + lineSpacing
+ }
+ }
+}
+
private struct WeightedVStack: Layout {
let weights: [CGFloat]