diff --git a/resources/xcode/NativePHP/Bridge/Functions/UIFunctions.swift b/resources/xcode/NativePHP/Bridge/Functions/UIFunctions.swift index b1acf46a..95c44113 100644 --- a/resources/xcode/NativePHP/Bridge/Functions/UIFunctions.swift +++ b/resources/xcode/NativePHP/Bridge/Functions/UIFunctions.swift @@ -11,14 +11,20 @@ import UIKit /// App-wide window background override set from PHP via `UI.SetBackground`. /// -/// Three surfaces read it (falling back to `systemBackground` when unset): +/// Four surfaces read it (falling back to `systemBackground` when unset): /// 1. `ContentView` — the base layer each native screen renders over, /// visible during screen transitions and before first content. /// 2. `NativeRootStackRenderer` — NavigationStack hosts its screens on /// its own container background (systemBackground, no SwiftUI /// override hook), so each stack screen backgrounds itself with /// this color instead. -/// 3. The UIKit windows, so overscroll and inset regions match. +/// 3. `NativeRootTabsRenderer` — TabView has the same opaque container, +/// so every tab level backgrounds itself the same way, plus the +/// search tab (a sibling of the tab `ForEach`, so it bypasses the +/// per-tab path). Both renderers share `WindowBackgroundModifier`, +/// and both apply it to their not-yet-published placeholders too, so +/// a screen's first frame doesn't flash the container through. +/// 4. The UIKit windows, so overscroll and inset regions match. /// /// `color` is only mutated on the main queue (see `SetBackground`). final class WindowBackgroundState: ObservableObject { diff --git a/resources/xcode/NativePHP/NativeRender/NativeRootStackRenderer.swift b/resources/xcode/NativePHP/NativeRender/NativeRootStackRenderer.swift index 16937567..93b25a85 100644 --- a/resources/xcode/NativePHP/NativeRender/NativeRootStackRenderer.swift +++ b/resources/xcode/NativePHP/NativeRender/NativeRootStackRenderer.swift @@ -70,7 +70,12 @@ struct NativeRootStackRenderer: View { if let cached = coordinator.rootNodeCache[uri] { renderRoot(cached, isRoot: isRoot) } else { + // Nothing cached for this URI yet. `screenView` backgrounds the + // rendered path; this branch never reaches it, so it takes the + // window background itself rather than flashing the + // NavigationStack's container. Color.clear + .modifier(WindowBackgroundModifier()) } } @@ -251,31 +256,37 @@ struct NativeRootStackRenderer: View { @ViewBuilder private func screenView(_ node: NativeUINode?) -> some View { - if let node = node { - // GlassEffectContainer coordinates `.interactive(true)` press - // animations across glass surfaces in this screen so they - // crossfade between idle and pressed states cleanly. Without - // a container, the per-glass-effect animation isn't scoped - // and the press transition renders as a visible flicker - // behind the touched element. iOS 26+ only. - NodeView(node: node) - // Tapping outside a focused field dismisses the keyboard, the - // same as on a chrome-less screen. Attached per-screen because - // the NavigationStack root itself is deliberately left unwrapped - // (mobile-air #308). - .dismissesKeyboardOnTap() - .withGlassContainer() - // NavigationStack hosts screens on its own container - // background (systemBackground — white in light mode) and - // SwiftUI exposes no override hook for it, so a dark app - // gets a white band in the bottom safe-area inset. When - // PHP set a window background (`UI.SetBackground`), paint - // it behind the screen extended through the safe areas. - // No-op when unset, preserving the stock appearance. - .modifier(StackScreenBackgroundModifier()) - } else { - Color.clear + Group { + if let node = node { + // GlassEffectContainer coordinates `.interactive(true)` press + // animations across glass surfaces in this screen so they + // crossfade between idle and pressed states cleanly. Without + // a container, the per-glass-effect animation isn't scoped + // and the press transition renders as a visible flicker + // behind the touched element. iOS 26+ only. + NodeView(node: node) + // Tapping outside a focused field dismisses the keyboard, the + // same as on a chrome-less screen. Attached per-screen because + // the NavigationStack root itself is deliberately left unwrapped + // (mobile-air #308). + .dismissesKeyboardOnTap() + .withGlassContainer() + } else { + // Placeholder until this level's tree publishes. + Color.clear + } } + // NavigationStack hosts screens on its own container background + // (systemBackground — white in light mode) and SwiftUI exposes no + // override hook for it, so a dark app gets a white band in the + // bottom safe-area inset. When PHP set a window background + // (`UI.SetBackground`), paint it behind the screen extended + // through the safe areas. Wraps both branches so the placeholder + // is backgrounded too — otherwise the frame before a screen's + // tree publishes flashes the container through. No-op when unset, + // preserving the stock appearance. The tabs renderer applies the + // same modifier. + .modifier(WindowBackgroundModifier()) } /// Renders one trailing action — plain Button when the action has @@ -464,18 +475,31 @@ private struct NavigationSubtitleModifier: ViewModifier { } -/// Backgrounds a stack-hosted screen with the PHP-set window background +/// Backgrounds a chrome-hosted screen with the PHP-set window background /// (`UI.SetBackground`), extended through the safe areas. NavigationStack -/// draws its own `systemBackground` container behind screen content with -/// no SwiftUI override hook — without this, a dark app shows a white band -/// in the bottom safe-area inset on every stack screen. No-op when no -/// override is set, preserving the stock appearance. -private struct StackScreenBackgroundModifier: ViewModifier { +/// and TabView both draw their own `systemBackground` container behind +/// screen content with no SwiftUI override hook — without this, a themed +/// app shows a system-background band in every safe-area inset the screen +/// content cannot reach. No-op when no override is set, preserving the +/// stock appearance. +/// +/// The paint goes in a background BUILDER, not a background value: the +/// builder form keeps the expanded color out of the parent's layout, so +/// ignoring the keyboard region paints under the keyboard without the +/// chrome measuring against a screen the keyboard never shrank. +/// +/// Shared by both chrome renderers, and applied to their placeholder +/// branches as well as their rendered ones: a level renders `Color.clear` +/// until its tree publishes, and an unbackgrounded placeholder flashes the +/// container through on the first visit to a screen. +struct WindowBackgroundModifier: ViewModifier { @ObservedObject private var windowBackground = WindowBackgroundState.shared func body(content: Content) -> some View { if let color = windowBackground.color { - content.background(color.ignoresSafeArea()) + content.background { + color.ignoresSafeArea() + } } else { content } diff --git a/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift b/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift index e9d1d305..1cbd2ae1 100644 --- a/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift +++ b/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift @@ -438,6 +438,10 @@ private struct PerTabContent: View { if let cached = coordinator.rootNodeCache[uri] { renderLevel(cached, isRoot: isRoot) } else { + // Nothing cached for this level yet. `levelContent` backgrounds + // the rendered path; this branch never reaches it, so it takes + // the window background itself rather than flashing the + // TabView's container on the first tap of a fresh tab. Color.clear .navigationTitle(fallbackTitle) .navigationBarTitleDisplayMode(.inline) @@ -449,6 +453,7 @@ private struct PerTabContent: View { textArgb: fallbackTextArgb, bgArgb: fallbackBgArgb )) + .modifier(WindowBackgroundModifier()) } } @@ -561,17 +566,31 @@ private struct PerTabContent: View { && $0.type != "top_bar_title" && !NativeRootHostRegistry.shared.consumes($0.type) } - if let content { - NodeView(node: content) - // Tapping outside a focused field dismisses the keyboard, the - // same as on a chrome-less screen. Attached to the screen - // content, not the TabView — a tap on the tab bar is the bar's - // business, and wrapping the TabView risks iOS 26's search - // capsule (mobile-air #308). - .dismissesKeyboardOnTap() - } else { - Color.clear + Group { + if let content { + NodeView(node: content) + // Tapping outside a focused field dismisses the keyboard, the + // same as on a chrome-less screen. Attached to the screen + // content, not the TabView — a tap on the tab bar is the bar's + // business, and wrapping the TabView risks iOS 26's search + // capsule (mobile-air #308). + .dismissesKeyboardOnTap() + } else { + // Placeholder until this level's tree publishes. + Color.clear + } } + // TabView hosts its screens on its own container background + // (systemBackground — white in light mode) with no SwiftUI + // override hook, so a themed app shows a system-background band + // wherever the safe-area-inset screen content cannot reach: + // behind the nav bar and behind the tab bar. Paint the PHP-set + // window background (`UI.SetBackground`) there, extended through + // the safe areas — the same treatment the stack renderer gives + // its screens. Wraps both branches so a tab's first frame, before + // its tree publishes, doesn't flash the container through. No-op + // when unset, preserving the stock appearance. + .modifier(WindowBackgroundModifier()) } } @@ -1000,6 +1019,12 @@ private struct SearchTabContainer: View { var body: some View { NavigationStack { NativeSearchTabRoot(query: query, itemNodes: itemNodes, mode: mode) + // The search tab is declared as a sibling of the `ForEach`, + // so it never passes through `PerTabContent` and needs the + // window background applied here. Inside the NavigationStack, + // matching the other renderers: outside it, the stack's own + // container would cover the paint. + .modifier(WindowBackgroundModifier()) } .searchable(text: $query, prompt: placeholder.isEmpty ? "Search" : placeholder) .onChange(of: query) { _, newValue in @@ -1042,6 +1067,8 @@ private struct NativeSearchTabRoot: View { let itemNodes: [NativeUINode] let mode: String + @ObservedObject private var windowBackground = WindowBackgroundState.shared + /// Plain-text representation of an item for client-side filtering. /// Element-kind items aren't filtered (no generic text extraction) /// — they always pass through. @@ -1083,6 +1110,11 @@ private struct NativeSearchTabRoot: View { List(displayed) { item in rowView(for: item) } + // A List paints its own `systemGroupedBackground`, which would + // cover the window background painted behind it and leave the + // search tab light in a themed app. Hidden only while an override + // is set, so the stock appearance is untouched without one. + .scrollContentBackground(windowBackground.color == nil ? .automatic : .hidden) .overlay { if itemNodes.isEmpty { if mode == "dynamic" {