crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 7feb9d2759fe22b32fb412c106b63fdc58ed51f0
parent 89c6eb043f7504a4c9f5ce372dad5e1e47771953
Author: Michael Camilleri <[email protected]>
Date:   Wed,  6 May 2026 22:17:56 +0900

Add iPad portrait layout

This commit adds a dedicated portrait arrangement to the iPad layout: the grid
occupies the top three-quarters, with the scoreboard and clue list sitting
side-by-side in the bottom quarter, and the same navigation-keys keyboard used
in landscape pinned to the bottom. The clue bar is dropped because the full
clue list is always on screen.

The 3:1 vertical split between grid and bottom pane is implemented as a small
WeightedVStack Layout that proposes proportional heights to its children, since
SwiftUI does not offer a built-in ratioed split for VStack. A PadLayout enum
(.landscape, .portrait, .none) replaces the previous isLandscapePad bool, and
pad-vs-phone selection moves from a UIDevice.orientationDidChangeNotification
observer reading UIWindowScene.effectiveGeometry to an onGeometryChange on the
puzzle view.

The sidebar clue list now scrolls the selected clue to the vertical centre
when currentID changes, matching the behaviour of the sheet presentation.
currentID only changes on clue or direction transitions, so typing within
a word does not retrigger the scroll.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Views/ClueList.swift | 12++++++++++++
MCrossmate/Views/PuzzleView.swift | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 97 insertions(+), 18 deletions(-)

diff --git a/Crossmate/Views/ClueList.swift b/Crossmate/Views/ClueList.swift @@ -75,6 +75,12 @@ struct ClueList: View { guard let currentID else { return } proxy.scrollTo(currentID, anchor: .center) } + .onChange(of: currentID) { _, newID in + guard let newID else { return } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(newID, anchor: .center) + } + } } } @@ -105,6 +111,12 @@ struct ClueList: View { guard let currentID else { return } proxy.scrollTo(currentID, anchor: .center) } + .onChange(of: currentID) { _, newID in + guard let newID else { return } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(newID, anchor: .center) + } + } } } diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -21,7 +21,12 @@ struct PuzzleView: View { @State private var isRevokedBannerDismissed = false @State private var isShowingShareSheet = false @State private var hasSolved = false - @State private var isLandscapePad = false + @State private var padLayout: PadLayout? + + private enum PadLayout { + case landscape + case portrait + } private func swatchImage(for color: PlayerColor) -> Image { let tint = UIColor(color.tint) @@ -50,9 +55,12 @@ struct PuzzleView: View { var body: some View { Group { - if isLandscapePad { + switch padLayout { + case .landscape: landscapePadLayout - } else { + case .portrait: + portraitPadLayout + case .none: phoneLayout } } @@ -105,11 +113,10 @@ struct PuzzleView: View { performDelete: performDelete, leaveSharedGame: leaveSharedGame )) - .onAppear(perform: updateLayoutTrait) - .onReceive(NotificationCenter.default.publisher( - for: UIDevice.orientationDidChangeNotification - )) { _ in - updateLayoutTrait() + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newSize in + updateLayoutTrait(for: newSize) } } @@ -145,17 +152,40 @@ struct PuzzleView: View { } } - private func updateLayoutTrait() { - isLandscapePad = UIDevice.current.userInterfaceIdiom == .pad - && currentInterfaceOrientation?.isLandscape == true + private var portraitPadLayout: some View { + VStack(spacing: 0) { + WeightedVStack(weights: [3, 1]) { + puzzleArea + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, 12) + + VStack(spacing: 0) { + Divider() + + HStack(alignment: .top, spacing: 0) { + PuzzleScoreboard(session: session, roster: roster) + .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) + + Divider() + + ClueList(session: session, presentation: .sidebar) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color(.secondarySystemBackground)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + controlsArea(showClueBar: false) + } } - private var currentInterfaceOrientation: UIInterfaceOrientation? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first { $0.activationState == .foregroundActive }? - .effectiveGeometry - .interfaceOrientation + private func updateLayoutTrait(for size: CGSize) { + guard UIDevice.current.userInterfaceIdiom == .pad, size != .zero else { + padLayout = nil + return + } + padLayout = size.width > size.height ? .landscape : .portrait } private func performResign() { @@ -228,7 +258,7 @@ struct PuzzleView: View { SuccessPanel(session: session, roster: roster) .transition(.move(edge: .bottom)) } else if showsCustomKeyboard { - KeyboardView(session: session, showsNavigationKeys: isLandscapePad) + KeyboardView(session: session, showsNavigationKeys: padLayout != nil) .transition(.move(edge: .bottom)) } } @@ -1073,3 +1103,40 @@ private struct AccessRevokedBanner: View { .padding(.top, 8) } } + +private struct WeightedVStack: Layout { + let weights: [CGFloat] + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + CGSize( + width: proposal.width ?? 0, + height: proposal.height ?? 0 + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let totalWeight = weights.reduce(0, +) + guard totalWeight > 0 else { return } + + var y = bounds.minY + for (index, subview) in subviews.enumerated() { + let weight = index < weights.count ? weights[index] : 0 + let height = bounds.height * weight / totalWeight + subview.place( + at: CGPoint(x: bounds.minX, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(width: bounds.width, height: height) + ) + y += height + } + } +}