ReviewOS

craft-native/craft

A tabbed screen's content stops above the tab bar, so the app's background shows as a grey band behind the floating bar

#425
Closed glennmichael123 opened this 5 days ago ·
5 days ago

On a CraftHybridController (a UITabBarController), every screen's content box ends ~138pt above the bottom of the device. The app's configured backgroundColor fills what is left, so the floating tab bar appears to sit on a grey container running full width to the screen edge, and the last row of content is clipped at a hard horizontal edge rather than scrolling under the bar.

The line

packages/ios/templates/CraftNativeScreen.swift:6079

rootBottomToSafeArea = rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)

The root stack is pinned to the safe-area bottom. Inside a tab bar controller that inset includes the tab bar, so the content box stops above it. Nothing is wrong with the tab bar itself — Craft never configures a UITabBarAppearance, and the band is not the bar's background.

How it was established

Measured on an iPhone 17 Pro simulator (iOS 26.5), app at @craft-native/ios 0.0.377, against a real app (HQ.training) rather than a fixture.

  1. The band and the content background were pixel-identical, (242, 242, 247). Not conclusive on its own: #f2f2f7 is also iOS's grouped-background grey, so either side could have been drawing it.
  2. The app's own screen wrapper was repainted #ff0000 and rebuilt. The content area went red; the band stayed (242, 242, 247). So the band is not the app's background showing through.
  3. The boundary sits at y=2208 of a 2622px capture — 736pt of 874, which is the safe-area bottom.
  4. (242, 242, 247) is exactly craft.config.json's "backgroundColor": "#f2f2f7" for that app, i.e. Craft's own root background.

So: content is bottom-pinned to the safe area, and Craft's root background fills the remainder.

What it should do

Content should reach the bottom of the view and scroll under the floating bar, with the bar's inset handed to scrollable content as a bottom content inset rather than shrinking the layout box. That is what the consuming app's own guidance already assumes — clearance belongs in contentContainerStyle, and the host owns safe-area insets — but an app cannot achieve it, because NativeNavigationOptions exposes no way to opt out: there is no translucent, contentInset, extendBehind or equivalent. tabBarHidden is the only related option, and hiding the bar is not the ask.

Care needed

That constraint governs every Craft screen, not only tabbed ones. Pinning the root to view.bottomAnchor unconditionally would push content under the home indicator on screens with no tab bar. The change wants to be conditional on a tab bar actually being present (and ideally on the content being scrollable), and verified across a few screen shapes — a plain screen, a tabbed screen, a sheet, and a screen with the tab accessory — not just one.

Also worth knowing

The native testing kit cannot catch regressions here. @stacksjs/stx's native testing kit models craft.navigation as open/back/push only, with no replace, no popToRoot and no way to assert setOptions effects — so screen presentation of this kind is verifiable only on a device. Separately, craft.navigation.replace(screen, params?) takes a screen name rather than a path; passing a path navigates nowhere, silently. Both are separate reports, noted here only because they shaped how this one had to be diagnosed.

4 days ago

Why this doesn't happen in Phone, Reddit or Instagram

The band isn't a styling difference — it's that Craft lays the bottom of a screen out in a way UIKit apps don't, and Craft already lays the top out the way they do. The two ends of the same screen get different treatment.

What a normal UIKit app does

A view controller inside a UITabBarController has its view sized to the full screen, extending behind the tab bar. The tab bar contributes to the controller's safe-area insets, and a scroll view pinned to the view's edges turns that inset into content inset rather than lost frame. Content scrolls under the bar, the last row still comes to rest above it, and there is never a flat strip — the bar floats over live content. On iOS 26's floating tab bar this is more visible still, since the bar is detached from the screen edge and you see content either side of it.

Apple's rule of thumb: inset the content, not the container.

What Craft does

CraftNativeScreen.swift:7583-7594:

let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leadingScroll?.view)
let fullEdgeToEdge = rootIsSafeAreaView
let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
rootTopToSafeArea?.isActive    = !topEdgeToEdge      // top: either flag frees it
rootBottomToSafeArea?.isActive = !fullEdgeToEdge     // bottom: only rootIsSafeAreaView frees it
...
scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never

edgeToEdge frees the top so the scroll view runs under the navigation bar and insets its own content — the comment immediately above says exactly that, "as UIKit's own screens do". The bottom is not included. It stays pinned to view.safeAreaLayoutGuide.bottomAnchor, so on a tabbed screen the content container's frame ends above the tab bar and the strip below it is the controller's own view.backgroundColor (CraftHybrid.swift:1194, config.resolvedBackgroundColor).

So a screen that is already edge-to-edge under the nav bar is still clamped above the tab bar. Same screen, same scroll view, opposite treatment at the two ends.

The machinery is already there and switched on

Two things in the same function show the intended behaviour is the UIKit one:

  • contentInsetAdjustmentBehavior = .automatic is set for exactly these screens, which is the API that absorbs a bar's inset into content inset. The bottom constraint means it never has a bottom overlap to absorb.
  • setContentScrollView(scroll, for: [.top, .bottom]) is called, which is what drives the tab bar's scroll-edge appearance and iOS 26 minimize behaviour. The scroll view never passes under the bar, so that effect cannot trigger.

Why the existing opt-in doesn't serve

fullEdgeToEdge is rootIsSafeAreaView (:8361) — true only when the screen's root node is a <SafeAreaView>. It releases all four edges at once. A screen whose root is a ScrollView would have to be rewrapped, and would then also lose its bottom inset against the home indicator and its horizontal insets. There is no proportionate way to ask for "content under the tab bar" alone.

Suggested shape

Include the bottom in the same condition the top already uses, for scroll-backed screens:

let bottomEdgeToEdge = fullEdgeToEdge || edgeToEdge
rootBottomToSafeArea?.isActive = !bottomEdgeToEdge
rootBottomToView?.isActive     = bottomEdgeToEdge

The blast radius is the reason to be careful, and it is the same one noted in the issue above: this constraint governs every Craft screen. A screen with no scroll view must keep its safe-area bottom, or its content lands under the home indicator — which is why gating on edgeToEdge (already scroll != nil) rather than unconditionally matters.

Worth verifying across: a plain screen with no tab bar, a tabbed screen with a scroll view, a tabbed screen without one, a sheet, a screen with tabBarHidden, and one with the tab accessory.

Still true from the original report

Craft never configures a UITabBarAppearance, so this is not the bar's own background — the strip is the controller's, exposed by the constraint.

4 days ago

Rechecked the October 29 update against current main and the published package. The quoted bottom-constraint implementation predates 30db7c6; the fix is already shipped in @craft-native/ios 0.0.387 (also verified in the published 0.0.380 archive).

Current code computes bottomEdgeToEdge = fullEdgeToEdge || tabbedScroll. A leading, enabled vertical ScrollView/FlatList reaching the root's bottom extends behind an actually visible tab bar. UIKit supplies the content inset once. Plain screens, sheets, hidden tabs, bounded/disabled/horizontal scrollers and fixed footers retain their safe-area bottom. This deliberately preserves the narrower safety conditions from the original report rather than extending every large-title scroll-backed screen unconditionally.

Fresh verification on main 62bc177:

  • iPhone 17 Pro / iOS 26.5: 55 native layout tests passed, 0 failures, 0 skips, including all ten #425 regressions: scroll/list bottom reachability, last-row clearance, system and legacy accessories, keyboard clearance, footer changes, hidden/restored bars, non-scrolling screens and sheets.
  • Renderer contract: 315 tests passed.
  • Downloaded the actual npm 0.0.387 tarball: CraftNativeScreen.swift is byte-for-byte identical to current main (SHA-256 1ce4b0936b4394e223a3532e1f7f068431a16daeaef510de462e4557bde65863).
  • The v0.0.387 release's iOS UIKit, navigation, Mobile E2E summary, exact native/source acceptance and publication checks succeeded.

Closing as fixed and shipped in v0.0.387. No new release or duplicate implementation was made for this recheck. An app generated from an older template needs its native project regenerated/rebuilt with the updated builder; changing its JS bundle alone does not replace compiled Swift. If a rebuilt app on 0.0.387 still shows the band, please include its screen tree and generated trackContentScrollView implementation so that remaining screen shape can be reproduced.

Local test receipt: /tmp/craft-425-recheck.xcresult.

5 days ago

Implemented on main in 30db7c6 (not published yet).

A leading vertical ScrollView/FlatList that fills the root now extends behind an actually visible tab bar. UIKit supplies the bottom content and scroll-indicator clearance; the host no longer shrinks that viewport above the bar. Layout rechecks handle bar visibility, accessory changes and adding/removing a fixed footer. Plain screens, sheets, bounded/disabled/horizontal scrollers retain their safe-area bottom; root SafeAreaView handling remains unchanged.

The native regression reproduced an 83pt gap without an accessory and 139pt with the iOS 26 system accessory. The fixed screenshot shows the authored red background reaching the bottom behind the floating bar. Tests also verify that the last row/cell can scroll above the bar and that retaining the scroll view while adding/removing a footer restores the right boundary.

Testing exposed a related double-count when the edge-to-edge scroller receives keyboard clearance automatically: a 300pt keyboard produced a 600pt inset. Explicit keyboard overlap now excludes the clearance UIKit already supplies.

Verification:

  • iPhone 17 Pro / iOS 26.5: 328 passed, 0 failed, 0 skipped (318 native unit tests + 10 simulator UI tests), including 10 new layout regressions.
  • Accessory tests cover the real iOS 26 UITabAccessory and the legacy navigation-controller 44pt inset/removal. Plain-screen and presented-sheet tests use real UIKit containment.
  • SDK: 800 passed, 8 skipped, 0 failed.
  • iOS generator: 143 passed; renderer contract: 315 passed; release tooling: 400 passed.
  • Typecheck, package builds, 43 package verification checks, pinned-Zig formatting and lint passed (0 lint errors, 26 existing warnings).

Local receipt: /tmp/craft-425-bottom-green.xcresult. This is one four-file commit pushed directly to main. Leaving the issue open pending remote CI; no release/tag was created and no published version contains this commit yet. The separate STX testing/navigation API concerns remain outside this change.

Sign in to comment on this issue.