Skip to content

iOS: paint the PHP window background behind tabs chrome - #358

Open
unlocdavid wants to merge 3 commits into
NativePHP:mainfrom
unlocdavid:fix/tabs-window-background
Open

iOS: paint the PHP window background behind tabs chrome#358
unlocdavid wants to merge 3 commits into
NativePHP:mainfrom
unlocdavid:fix/tabs-window-background

Conversation

@unlocdavid

Copy link
Copy Markdown
Contributor

UI.SetBackground is documented as setting what shows behind transparent system bars and safe-area insets, but under a tabs root it is a silent no-op.

NativeRootStackRenderer applies StackScreenBackgroundModifier to every stack-hosted screen, so the PHP-set color fills the safe-area insets there. NativeRootTabsRenderer has no equivalent: TabView hosts its screens on its own
systemBackground container, and screen content is inset below the chrome, so a themed app shows a white band behind the nav bar and behind the tab bar in light mode. There is no app-side workaround — a full-bleed element inside the screen
root cannot reach those strips, and no "ignore safe area" class exists.

This shares the existing modifier rather than adding a second one:

  • StackScreenBackgroundModifierWindowBackgroundModifier, no longer private, with its doc comment generalized from NavigationStack to both chrome roots.
  • NativeRootTabsRenderer.PerTabContent applies it alongside .dismissesKeyboardOnTap().

No behavior change when no background is set: the modifier returns content untouched, so the stock appearance and iOS 26's adaptive Liquid Glass bars are preserved. Verified on device (iOS 26 simulator and cold launch from a killed process): with the patch the color fills edge to edge behind both bars and the glass survives; without it, the seam returns.

`UI.SetBackground` is documented as controlling "what shows behind
transparent system bars and safe area insets", and the stack renderer
honours it: `StackScreenBackgroundModifier` paints the colour behind
each stack screen, extended through the safe areas, because
NavigationStack draws its own `systemBackground` container with no
SwiftUI override hook.

TabView has the same container and no equivalent modifier, so under a
tabs root the call is silently a no-op. A themed app gets a
system-background band wherever the safe-area-inset screen content
cannot reach — behind the nav bar and behind the tab bar — while the
identical screen under a stack layout renders correctly.

Apply the same modifier in the tabs renderer's level content. The
modifier is shared rather than duplicated, so it loses its `private`
(file-scoped in Swift) and the stack-specific name: it is now
`WindowBackgroundModifier`, still declared in the stack renderer, so no
new file and no project.pbxproj change.

No-op when no override is set, so the stock appearance is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gwleuverink

Copy link
Copy Markdown
Contributor

Thanks for the fix David. Great PR 🙏🏻

One thing I found while checking if this is now consistently applied everywhere; A search tab still misses it. Tab(role: .search) renders SearchTabContainer, a sibling of the ForEach, so it never reaches PerTabContent and the screen stays light.

The Color.clear branches at NativeRootTabsRenderer.swift:441 and :583 keep the band as well. That's what a tab renders until its tree publishes, so the first tap on a fresh tab flashes. NativeRootStackRenderer.swift:278 has it too.

Are you up to adding those? It'll close the inconsistency everywhere :)

Repro, with nativephp_call('UI.SetBackground', json_encode(['color' => '#0F172A'])) in mount():

<top-bar title="Tabs root" />

<bottom-nav label-visibility="labeled">
    <bottom-nav-item id="one" icon="home" label="One" url="/tabs-bg" :active="true" />
    <bottom-nav-item id="two" icon="person" label="Two" url="/tabs-bg-two" />
</bottom-nav>

<column class="w-full h-full p-6 bg-[#0F172A]">
    <text class="text-xl text-white">Tabs root</text>
</column>

For the search tab, swap the second item for this one and add searchItems() so it has something to list:

<bottom-nav-item id="find" icon="magnifyingglass" label="Find" url="/tabs-search" search :active="true" />
public function searchItems(): array
{
    return ['Alpha', 'Bravo', 'Charlie'];
}

Small one while you're in there: the WindowBackgroundState docblock in UIFunctions.swift lists three surfaces. Mind adding the tabs renderer to it? That comment is the only place this API's behaviour is written down.

unlocdavid and others added 2 commits August 21, 2026 10:55
`WindowBackgroundModifier` reached the tab levels but not every surface a
tabs root puts on screen.

`Tab(role: .search)` renders `SearchTabContainer`, declared as a sibling
of the tab `ForEach` (Apple's "if inside TabView" pattern), so it never
passes through `PerTabContent` and the search tab stayed light in a
themed app. It takes the modifier inside its own NavigationStack —
outside it, that stack's container would cover the paint. Its results
`List` paints `systemGroupedBackground` over anything behind it, so the
scroll background is hidden as well, but only while an override is set,
leaving the stock appearance untouched.

A level renders `Color.clear` until its tree publishes. Unbackgrounded,
that first frame flashes the container through on the first visit to a
screen, so the placeholder branches take the background too — in both
renderers, since the stack renderer has the same placeholders.

Where the `if/else` is content-versus-placeholder the modifier lifts
above it; where the populated branch already picks it up further down,
only the placeholder carries it, so nothing paints twice.

`WindowBackgroundState`'s docblock is the only place this API's
behaviour is written down, and now lists the tabs renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`WindowBackgroundModifier` passed its expanded color to
`background(_:alignment:)`, so the color's `.ignoresSafeArea()` reached
the parent's layout. The chrome then measured against a screen the
keyboard never shrank: on the search tab — the one place a text field
sits inside a screen this modifier backgrounds — typing, clearing the
field and leaving the tab left the tab bar latched to a layout that no
longer existed, and gone. The stack renderer had the same exposure on
any screen with a text field.

Move the paint into the background BUILDER. The expanded color stays out
of the parent's layout, so the chrome keeps measuring against the real
screen while the paint still reaches under the notch, the home indicator
and the keyboard.

Scoping the ignore to `.container` fixes the latch too, but stops the
paint at the keyboard's edge and leaves a black band behind every
keyboard. The builder keeps both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@unlocdavid

Copy link
Copy Markdown
Contributor Author

To be clear, this is Claude's work. I just tested it on the simulator. I lack a deep understanding of the changes.


All three done.

Search tab. SearchTabContainer takes the modifier inside its own NavigationStack — attached outside it, that
stack's container covers the paint. It needed a second piece to actually land: the results List paints its own
systemGroupedBackground over anything behind it, so the window background alone fixed only the bands and the body
stayed light. .scrollContentBackground is hidden alongside it, but only while an override is set, so the stock
appearance is untouched.

Placeholders. Covered in both renderers — four sites rather than three. NativeRootStackRenderer.destination
has the same uncached-URI fallback as the one you spotted at :278, and it never reaches the modifier either.

Where the if/else is content-versus-placeholder (screenView, levelContent) the modifier lifts above the whole
thing, so there is one call site that cannot drift. Where the populated branch already picks it up further down
(destination, levelView) only the placeholder carries it, so nothing paints twice.

Docblock. WindowBackgroundState now lists the tabs renderer as a fourth surface, including the search tab and
the placeholder branches.

One more fix, found while verifying the above. WindowBackgroundModifier passed its expanded color to
background(_:alignment:), so the color's .ignoresSafeArea() reached the parent's layout and the chrome measured against a screen the keyboard never shrank. The search tab is the one place a text field sits inside a screen this modifier backgrounds, so it surfaces there: type, clear the field, leave the tab, and the tab bar is gone. The stack
renderer had the same exposure on any screen with a text field. Moving the paint into the background builder keeps
the expanded color out of the layout while it still reaches under the notch, the home indicator and the keyboard.
Scoping the ignore to .container also fixes the latch, but stops the paint at the keyboard's edge and leaves a
black band behind every keyboard — verified on device, hence the builder.

Separately, and not this PR: native-ui's <native:background-layer> never shows on iOS under a tabs root.
NativeUIBackgroundLayerHost mounts around the whole tree at SwiftUINodeRenderer.swift:51, so the layer sits
beneath the chrome renderer and the chrome container is opaque. With UI.SetBackground never pushed — so
WindowBackgroundModifier no-ops — the layer region still renders white on device, which puts this before the patch
rather than caused by it: the patch changes the colour of the occlusion, not its existence. Android renders the same
tree correctly through LocalBackgroundLayerPresent into Scaffold(containerColor = Transparent); iOS has no
equivalent signal, and SwiftUI exposes no containerColor to set. I will open it on its own.

Verified on device (iOS 26 simulator): the search tab takes the window background including behind its results
list; a tab's first tap no longer flashes the container; the bars stay filled edge to edge with Liquid Glass intact;
and the keyboard raises and dismisses cleanly on both a tabs root and a stack root, with the paint reaching under it
and the tab bar surviving the search field's clear button.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants