From 7f94f03ed70833e7db2b23d2611e106e01a40610 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 20 Jul 2026 09:03:10 -0700 Subject: [PATCH] host the bottom bar in a navigation stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS bottom bar had no navigation host: ContentView sits bare in the WindowGroup, so `.bottomBar` items were bridged onto the window root and board mutations tore them down — the bar visibly blanked and returned on every stock draw. It also left `.toolbar(_:for: .bottomBar)` inert, which is why picker visibility was faked by emptying the item list. Wrap the board in a NavigationStack that only ever hosts (never pushes, own navigation bar hidden), and drive picker visibility through the real visibility API. The stack adds a subtree that is measured before layout and reports a frame with a real origin but no size, e.g. (148, 0, 0 x 0). The single-frame preference keys rejected only `.zero`, so that candidate passed the guard and clobbered the live stock/waste frames — every card flight then launched from the top of the screen instead of the pile. Guard on size instead, which is what the check meant all along. Verified the flight planner's inputs are unchanged with the stack in place: stock (12, 114.667, 47 x 68.15) and waste (67, 114.667, 63.92 x 68.15) match the pre-change build exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/Shared/ContentView.swift | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/ContentView.swift b/ComputerSolitaire/Views/Shared/ContentView.swift index 9e034fb..7136506 100644 --- a/ComputerSolitaire/Views/Shared/ContentView.swift +++ b/ComputerSolitaire/Views/Shared/ContentView.swift @@ -12,15 +12,18 @@ struct DropTargetFrameKey: PreferenceKey { } } -// Single-frame keys must ignore the default: on macOS, sibling subtrees that -// never set the key still run reduce with .zero, and a last-wins reducer lets -// that erase the real frame (the draw animation then never runs). +// Single-frame keys must ignore sizeless candidates: sibling subtrees that +// never set the key still run reduce, and a last-wins reducer lets them erase +// the real frame (the draw animation then never runs). Rejecting only `.zero` +// is not enough — a subtree measured before layout reports a rect with a real +// origin but no size, e.g. `(148, 0, 0 x 0)`, which clears that test and +// clobbers the live frame. Size is what makes a candidate meaningful here. struct StockFrameKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { let next = nextValue() - if next != .zero { + if !next.isEmpty { value = next } } @@ -31,7 +34,7 @@ struct WasteFrameKey: PreferenceKey { static func reduce(value: inout CGRect, nextValue: () -> CGRect) { let next = nextValue() - if next != .zero { + if !next.isEmpty { value = next } } @@ -255,11 +258,34 @@ struct ContentView: View { } } + /// Gives the bottom bar a real navigation host on iOS. + /// + /// `.bottomBar` expects to be owned by a navigation container. Hosted bare + /// in the `WindowGroup` the items are bridged onto the window root, where + /// board mutations tear the bar down and `.toolbar(_:for: .bottomBar)` has + /// nothing to act on — which is why bar visibility used to be faked by + /// emptying the item list. + /// + /// The stack is purely a host: it never pushes and its own navigation bar + /// is hidden. It does add a subtree that reports sizeless frames before + /// layout, so the single-frame preference keys reject those explicitly. + private func toolbarHost(for view: some View) -> some View { +#if os(iOS) + NavigationStack { + view + .toolbar(.hidden, for: .navigationBar) + .toolbar(isShowingGamePicker ? .hidden : .visible, for: .bottomBar) + } +#else + view +#endif + } + // The decoration helpers are generic over their content — never AnyView. // Type erasure here would strip the board's structural identity, forcing // SwiftUI to diff the whole scene through an opaque box on every update. private func sceneDecorations(for baseView: some View) -> some View { - let toolbarView = applyToolbar(to: baseView) + let toolbarView = toolbarHost(for: applyToolbar(to: baseView)) let sheetsView = applySheets(to: toolbarView) return applyObservers(to: sheetsView) }