Skip to content

android: draw the bottom bar as an overlay so its glass has a backdrop (#1836) - #1837

Merged
ryanbr merged 4 commits into
mainfrom
feat/1836-overlay-glass-bar
Sep 3, 2026
Merged

ryanbr merged 4 commits into
mainfrom
feat/1836-overlay-glass-bar

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

Draft — this is a layout change I cannot see. It compiles and the suite is green, but somebody has to look
at it on a phone before it should land.

Raised on #1836.

The bar was never the problem

GlassBottomBar is already built to float: pill shape, 22 dp inset, navigationBarsPadding(), hairline
rim, 0.80 alpha, soft shadow. It rendered as a dark strip regardless — because of where it was mounted,
not how it was styled.

It sat in Scaffold(bottomBar = …), a slot outside the screen content. Each screen paints its own
backdrop via LiquidScreenSky / BackgroundImageBackdrop, so that backdrop stopped where the bar began,
and behind the glass there was only containerColor = Palette.surfaceBase.

Translucency over a flat colour is a slightly different flat colour. With a custom background (#1234) it
reads as a block cut out of the image, which is what the side-by-side screenshots show.

What changed

The bar is drawn as a sibling over the Scaffold, so it floats on whatever the current screen painted.
Same composable, same insets — only its parent moved. The slot stays but empty, so the Scaffold reserves
nothing, and the inset it used to contribute is applied to the NavHost. Content clears the bar exactly as
before; only what sits behind the glass differs.

Deliberately not in this change

  • Content scrolling under the glass. That needs every scrollable to add the bar height to its own
    contentPadding; no shared modifier can do it. LocalBottomBarHeight is exposed for that work, screen
    by screen, with a device to look at.
  • The active-tab pill. BarSlot changes only colour and weight, where the system iOS bar draws a
    filled rounded background behind the selected item. That is the other visible gap and belongs in its own
    change.
  • Real backdrop blur. Compose has no cheap way to sample what is behind a composable, so this is not
    blur parity — it is making the translucency that already exists show something.

Why iOS looks native

Because it is. RootTabView uses SwiftUI's TabView, so iOS 26 supplies the Liquid Glass bar, the
selected-item pill, real blur and scroll behaviour for free. Android's bar is hand-built and always will
be an approximation; this closes the part of the gap that was a mounting mistake rather than a platform
limit.

On #90

#90 is an open prototype (July) for a scroll-reactive version of this same bar, editing the same
composable and two months stale. Whichever lands first, the other should be rebased onto it rather than
both restructuring GlassBottomBar in parallel. Notes on that PR separately.

Verification

  • compileFullDebugKotlin clean; full Android suite 5089 tests, 0 failures; doc-comment gate clean.
  • Not seen on a device. The specific risks are the bar overlapping content on a screen whose scrollable
    does not honour the NavHost inset, and the overlay's behaviour with the keyboard open.

Now behind a setting, default off

Settings → Appearance → Bottom bar overlay, default off. Merging changes nothing on screen until
someone turns it on.

The off-path resolves to exactly what ships today — the slot renders the bar, the NavHost pads by inner,
and the overlay composable is never composed, so its measurement never runs. What changes unconditionally
is structural only: run { } became a Box, two remembered values the off-path never reads, and the
BottomBarStyleStore object.

That is the right shape for this change. Both inset bugs found while writing it were invisible without a
device, so shipping it as the only option would have been asking users to find the third one. A switch
makes it reachable for testing without a special build, while the default stays inert.

Still unverified on a device. It should be described as experimental wherever it is announced until
someone reports back from having flipped it.

#1836)

The bar was already built to float — pill shape, 22dp inset,
navigationBarsPadding, hairline rim, 0.80 alpha, soft shadow. It rendered as
a dark strip anyway, and the reason was where it was MOUNTED, not how it was
styled.

It sat in Scaffold(bottomBar = ...), a slot OUTSIDE the screen content. Each
screen paints its own backdrop through LiquidScreenSky / BackgroundImageBackdrop,
so that backdrop stopped where the bar began: behind the glass there was only
containerColor = surfaceBase. Translucency over a flat colour is a slightly
different flat colour, which is exactly what a user comparing it to the iOS bar
was seeing — most obvious with a custom background (#1234), where the bar reads
as a block cut out of the image.

Drawn as a sibling over the Scaffold instead, so it floats on whatever the
current screen painted. Same composable, same insets: only its parent moved.
The slot stays but empty, so the Scaffold reserves nothing, and the inset it
used to contribute is applied to the NavHost — content clears the bar exactly
as before.

LocalBottomBarHeight is exposed for the half deliberately NOT done here:
letting content scroll UNDER the glass needs each scrollable to add that to its
own contentPadding, which no shared modifier can do. That wants a device to
look at, screen by screen.

WHY iOS LOOKS NATIVE: it IS native. RootTabView uses SwiftUI's TabView, so iOS
26 supplies the Liquid Glass bar, the selected-item pill and real backdrop blur
for free. Compose has no cheap backdrop blur, so this is not blur parity — it
is making the existing translucency show something. The active-tab pill is the
other visible gap and is not in this commit.

Two placement traps hit while writing this, both mine: the declaration first
landed between @OptIn(ExperimentalMaterial3Api::class) and the function, so the
annotation applied to my val and three experimental-API errors appeared inside
AppRoot. Same shape as the stacked-doc trap, fifth time this session.

Kotlin: full suite 5089/0; compile and doc-comment gate clean. NOT verified on
a device - this is a layout change I cannot see.
Reviewing my own change for cost turned up a correctness bug instead.

Moving the bar out of Scaffold(bottomBar = ...) meant losing the measured
`inner` inset, and I replaced it with a constant 76.dp. The bar's height
includes navigationBarsPadding(), which is roughly 24dp under gesture
navigation and roughly 48dp under 3-button navigation, and changes again on
rotation or when a foldable opens. So a constant is wrong by about 24dp on
exactly the 3-button device the original report came from - content would sit
behind the bar.

Measured with onSizeChanged and fed back as the NavHost's bottom padding: one
extra layout pass at startup, then stable. Trading a measured value for a
guess to avoid one pass would be the wrong trade.

Also removed the LocalBottomBarHeight I had added for future per-screen
padding. It was declared but never provided, so every reader would have got
0.dp - an API that looks available and silently does nothing is worse than no
API. It can be added when something actually consumes it.

Kotlin: full suite 5089/0; compile and doc-comment gate clean. Still not seen
on a device.
Second inset bug in this PR, the mirror of the first. Having fixed a
hardcoded height by measuring it, I then added that measurement to a value
that already contained part of the same thing.

The Scaffold does not set contentWindowInsets, so it defaults to
WindowInsets.systemBars and `inner.bottom` already carries the navigation-bar
inset. The bar's measured height carries it too, through its own
navigationBarsPadding(). `.padding(inner).padding(bottom = barHeight)`
therefore counted that inset twice and left roughly a navigation bar's worth
of dead space between content and the bar — widest on 3-button navigation,
which is the device in the report.

Take top and sides from the Scaffold and REPLACE the bottom with the bar
height. The bar owns the navigation inset; content only has to clear the bar.

Both bugs came from treating an inset as a number to combine rather than
asking what already accounts for what. Neither is visible without a device,
which is why this stays a draft.

Kotlin: full suite 5089/0; compile and doc-comment gate clean.
)

Both layouts now ship. Settings > Appearance > Bottom bar overlay, default
OFF, so nothing changes for anyone until they ask for it.

That is the right default for this change specifically. The overlay is what
the bar was built for — its glass finally has a backdrop instead of sitting on
surfaceBase — but it is app-shell layout that no test can judge, and the two
inset bugs found while writing it were both invisible without a device. A
switch turns "unverified change everyone gets" into "unverified change
someone can try, and turn off".

The default path is the shipped Scaffold slot, byte-identical to before: the
slot renders the bar and the NavHost pads by `inner`. The overlay path empties
the slot, draws the bar as a sibling over the Scaffold, and replaces the
Scaffold's bottom inset with the measured bar height.

BottomBarStyleStore is snapshot-backed, mirroring BackgroundImageStore, so the
toggle applies without a relaunch.

Two more placement traps hit while writing this — the store landed under
AppRoot's KDoc and the pref key under the Clock key's, orphaning both. Seventh
and eighth this session. The gate caught them; I did not.

Kotlin: full suite 5089/0. compile, doc-comment, i18n audit and
lintVitalFullRelease all clean. String added in all 7 locales.
@ryanbr
ryanbr marked this pull request as ready for review September 3, 2026 02:42
@ryanbr
ryanbr merged commit 52bf64f into main Sep 3, 2026
4 checks passed
@ryanbr
ryanbr deleted the feat/1836-overlay-glass-bar branch September 3, 2026 02:45
ryanbr added a commit that referenced this pull request Sep 3, 2026
versionCode 419 -> 420, CURRENT_PROJECT_VERSION 300 -> 301. versionName and MARKETING_VERSION stay
at 11.1.1 - a testing build, not a version bump.

Adds the optional overlay bottom bar (#1837) on top of what 419 carried. It is OFF by default, so
this build looks identical until Settings > Appearance > Bottom bar overlay is turned on - which is
the point of cutting it: the switch makes an unverified layout reachable without a special build.

Still open on this build: the 11.1.0 startup report remains undiagnosed, and the overlay bar has
never been seen on a device.
ryanbr added a commit that referenced this pull request Sep 3, 2026
#1837 shipped a toggle that changed nothing on screen. Moving the bar out of the Scaffold slot was
not enough on its own: a screen's backdrop is painted INSIDE the screen, and the overlay path still
inset the NavHost by the bar height, so the screen and its backdrop stopped exactly where they had
before. The bar floated over a strip of the shell's container colour - identical layout, and a dark
band where the background should have continued.

The screen now reaches the bottom edge so its full-bleed backdrop paints behind and around the glass,
and ScreenScaffold adds the bar's height to the scrolling CONTENT's bottom padding instead. That
padding sits inside the scroll viewport, so content passes under the bar while scrolling and the last
row still comes to rest clear of it.

One change rather than four: every top-level screen routes through ScreenScaffold. StepsCalibration
is the one NavHost destination that scrolls without it and is handled directly.

Still behind Settings > Appearance > Bottom bar overlay, default off. Raised on #1836.
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.

1 participant