Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions resources/xcode/NativePHP/Bridge/Functions/UIFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
52 changes: 42 additions & 10 deletions resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -449,6 +453,7 @@ private struct PerTabContent: View {
textArgb: fallbackTextArgb,
bgArgb: fallbackBgArgb
))
.modifier(WindowBackgroundModifier())
}
}

Expand Down Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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" {
Expand Down
Loading