ReviewOS

craft-native/craft

A Craft screen never fills the display: the app background shows as a strip above and below the content

#426
Closed glennmichael123 opened this 4 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
4 days ago

Scope widened after filing. This started as the strip below a tabbed screen. Measuring against an app that gets it right shows the same defect at the top as well, from a different cause. Both are covered here; see the comment with the Instagram comparison for the numbers and the split.

Acceptance criterion: no row of the display is the app's background colour where content exists to fill it. Instagram on iOS 26 measures 0 dead rows at both edges. A Craft app measures 39pt at the top and 34pt at the bottom.

v0.0.387 shipped a fix for #425. On a real app it changes nothing. The grey strip is still there, and it needs to be gone — not narrowed, not conditional, gone.

Measured, not eyeballed

iPhone 17 Pro simulator, iOS 26.5, 402x874pt. Same app, same screen, three builds. Lowest row containing anything other than flat background:

buildcontent reachesdead strip
craft-native 0.0.380 (before the fix)y=2558 of 262221pt
craft-native 0.0.387 (with the fix)y=2558 of 262221pt
0.0.387, different screen, fresh buildy=2558 of 262221pt

Identical. The fix did not move a single pixel on any screen we ship.

The strip samples rgb(241,241,246) at the screen edges, falling to rgb(236,236,241) under the bar's shadow. backgroundColor in our craft.config.json is #f2f2f7 = rgb(242,242,247). It is the app's background, painted where content should be.

What a user sees: the floating tab bar's own translucency shows live content behind the pill, and then a flat grey band under it, running the full width to the bottom of the screen. The content that should show through below the bar simply is not there. Nothing else on iOS does this. Phone, Reddit, Instagram and Apple's own Music do not have it.

Why the v0.0.387 fix never engages

CraftNativeScreen.swift:

var tabbedScroll = false
if let scroll, scroll === leadingScroll?.view, scroll.bounds.height > 0,
   ... {
    let viewport = scroll.convert(scroll.bounds, to: rootStack)
    tabbedScroll = abs(viewport.maxY - rootStack.bounds.maxY) < 0.5
}
let bottomEdgeToEdge = fullEdgeToEdge || tabbedScroll

Two clauses rule out ordinary screens:

  1. scroll === leadingScroll?.view — the scroll view has to be the root's leading child. Ours is nested two levels down, under a positioning wrapper and a screen wrapper. That is not exotic; it is what any screen with a modal, an overlay or a web/native fork looks like.
  2. viewport.maxY == rootStack.bounds.maxY — the scroll view has to be the last thing in the root stack. Ours has siblings after it: a modal block and a notice block, both normally not rendering, both still children.

So tabbedScroll is false on every screen we have, rootBottomToSafeArea stays active, and the behaviour is exactly what it was before the fix. The fix is written for a screen whose root is a scroll view. Real screens are not shaped that way, and a fix that only applies to the simplest possible hierarchy does not fix the bug.

What this needs to be

No screen should ever paint the app's background colour into the strip between its content and the bottom of the display. Not 21pt of it, not 1pt. If a tab bar is on screen, content runs to the bottom of the display and the bar floats over it, with the scroll view's content inset — which Craft already sets to .automatic — providing the clearance. That is the whole of the behaviour, and it should not depend on where the scroll view sits in the hierarchy.

Concretely, one of:

  • find the screen's primary vertical scroller wherever it is in the tree rather than requiring it to be the leading child, and ignore siblings that render nothing; or
  • drop the structural test entirely: when a visible tab bar overlaps the screen, pin the root to view.bottomAnchor and let content inset handle clearance. A screen with no scroll view keeps its safe-area bottom, which is the one case the current guard is genuinely protecting.

The careful guards in the current implementation — checking the bar is visible, in the same window and overlapping — are right and worth keeping. It is the structural requirement that defeats it.

Repro

Any screen shaped like this, in a tabbed app, on iOS 26:

<div class="relative flex-1">
  <div class="flex flex-1 flex-col">
    <div data-native="ScrollView" class="flex flex-1 flex-col"> ... </div>
    <div :if="showModal"> ... </div>
  </div>
</div>

Screenshot it and sample the bottom 21pt. It is backgroundColor, not content.

Happy to test a build against a real app with ~90 screens the moment there is one.

3 days ago

Fixed in v0.0.395. Verified on device, at both edges.

The mechanism, which this issue never pinned down

I said in this thread that we had not established why the strip survived when edgeToEdge was already true. The answer is that it was never the navigation bar's background, which is why making the bar transparent never removed it.

It was UIScrollView.topEdgeEffect / bottomEdgeEffect — the iOS 26 backdrop UIKit draws under floating chrome, drawn independently of the bar's appearance. applyNavigationBarAppearance could not have removed it, and neither could any of the backgroundColor / navigationBarSolid combinations tried here. c649fef8 hides it, and 05986c5e plus the bounds.height > 0 guard in leading(_:) is what lets the leading-scroll rule resolve through nested flex wrappers so the condition engages at all.

Why it looked unfixed for several releases

Worth recording for anyone chasing something similar. Our iOS shell is generated from the installed Craft package at build time and is gitignored. A stale local copy kept reporting the old behaviour no matter which Craft version was pinned: CraftNativeScreen.swift in the generated sources contained zero occurrences of topEdgeEffect while the installed v0.0.395 package contained them. Rebuilding regenerated the shell and the strip disappeared on the first run. So a report that "version N did not fix it" is only meaningful if the generated sources are confirmed to carry the change.

One regression

Hiding the effect also removed the legibility separation it provided at the top edge — content now passes under the status bar and the navigation title perfectly sharp and collides with both. Filed separately as #430, with UIScrollEdgeEffectStyle.softStyle as a suggested middle ground.

Closing this one; the wash itself is gone.

4 days ago

What "fixed" should mean, measured against an app that gets it right

Instagram on iOS 26, and a Craft app, sampled the same way — scan each row for anything that is not flat background, from the top down and from the bottom up:

appfirst content rowlast content rowdead at topdead at bottom
Instagram02867 of 286800
Craft app (scrolled)1162518 of 262239pt34pt

Instagram puts live content in every row of the display. The status bar sits over a photo. The floating tab pill has video running behind it and below it, to the final row. There is no bar background anywhere, at either edge — the chrome floats and the content owns the screen.

That is the target, and it is a single requirement covering both edges: no row of the display should ever be the app's background colour where content exists to fill it. Not 39pt at the top, not 34pt at the bottom, not 1pt.

Top and bottom are failing for different reasons

Worth separating, because the fix for one will not fix the other:

  • Bottom — the layout constraint described above. tabbedScroll requires the scroll view to be the root's leading child and reach the root stack's bottom, so rootBottomToSafeArea stays active on any screen with wrappers or siblings.

  • Top — not that constraint. On the screen measured here largeTitle: true is set, so edgeToEdge is already true, rootTopToView is active and contentInsetAdjustmentBehavior is .automatic. The content is genuinely behind the bar. It still is not visible, which points at the bar's appearance rather than the layout: applyNavigationBarAppearance builds a configureWithOpaqueBackground() appearance filled with config.resolvedBackgroundColor and assigns it to standardAppearance as well as scrollEdgeAppearance. An opaque bar in the app's background colour hides whatever scrolls behind it, where iOS would normally use a blurred material once scrolled.

    One caveat on that second point, stated plainly: that method only runs when a screen passes backgroundColor or navigationBarSolid, and the screen measured here passes neither. So either something else is opaquing the bar, or the 39pt is the large-title area rather than the bar itself. We have not pinned which, and would rather say so than guess.

Happy to instrument a build and report exact view geometry for both edges if that is useful.

4 days ago

Constraint on the fix: move nothing, only stop painting

To be unambiguous about what we are asking for, because there is a wrong way to make the measurement pass.

Do not change the chrome's position, size or layout. The navigation bar stays where it is. The floating tab bar stays where it is, same size, same inset from the edges. Buttons, titles and tab items do not move by a point. No screen should need restructuring to benefit, either.

The only thing that changes is that the bars stop painting an opaque background. Content extends beneath them and is visible through them, as it already is through the tab pill's own material.

That is precisely what Instagram does. Its + and heart buttons sit where any iOS app's would; the photo simply runs underneath them. Its tab pill is an ordinary floating bar; the video runs behind and below it. Nothing about the chrome is unusual — the content is just not being cut off behind it.

So, restating the criterion with the constraint attached:

  • No row of the display is the app's background colour where content exists to fill it.
  • And the chrome is laid out exactly as it is today.

A fix that satisfies the first by shrinking the bar, insetting it differently, moving the tab items or requiring apps to rewrite their view hierarchy is not the fix we are asking for.

4 days ago

The simplest statement of this, which should probably be the headline

Craft already draws its chrome as floating button groups: a circular button here, a rounded pill of two buttons there, a rounded tab bar pill at the bottom. Each one has its own material, its own corner radius, its own shadow. They are, visually, floating.

A floating element does not come with a backdrop. But Craft still paints the full-width bar region behind these groups in the app's background colour. So a pill that is designed to hover ends up sitting on a slab of grey — and in dark mode, black — that runs edge to edge.

That single description covers everything in this issue:

  • The strip above the content is the navigation bar's region, painted behind the floating buttons.
  • The strip below the content is the tab bar's region, painted behind the floating tab pill.

It is the same mistake at both ends: the groups float, their containers do not.

Whatever the implementation turns out to be — a layout constraint at the bottom, an opaque UINavigationBarAppearance at the top — the intent is one line: the region behind a floating button group should not be painted at all. The group brings its own material. Everything behind it should be the app's content.

Instagram is just an app that already does this. Its buttons float over a photo; its tab pill floats over a video. There is no third thing between the chrome and the content.

Sign in to comment on this issue.