From 61f8c58c4938da3f165dd8ab6bd87593947da6c6 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Wed, 8 Jul 2026 23:51:51 +1200 Subject: [PATCH 1/4] =?UTF-8?q?PROTOTYPE:=20scroll-reactive=20bottom=20bar?= =?UTF-8?q?=20=E2=80=94=20recede=20on=20scroll-down,=20return=20on=20up=20?= =?UTF-8?q?(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instagram-style: the floating GlassBottomBar subtly recedes (shrink + fade + slide toward the edge) as you scroll DOWN into content and returns when you scroll UP, so the app feels more dynamic without hiding the tabs. - One NestedScrollConnection on the NavHost drives it, so every screen gets the behaviour with zero per-screen wiring. onPreScroll only flips a Boolean on a direction change; a 220ms tween animates a single `collapse` float. - The bar applies `collapse` as a graphicsLayer transform ONLY (scale ~0.88, alpha to 0.6, translateY 22dp, bottom-centre pivot). No relayout — the Scaffold keeps the bar's reserved space — so scrolling stays smooth (GPU transform per frame). - Reduce Motion keeps the bar fully shown (no animation). Subtle by design; the transform constants are the tuning knobs. Android only for now. Android compiles locally. --- .../app/src/main/java/com/noop/ui/AppRoot.kt | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index ec399d6c2..822c45432 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -3,6 +3,12 @@ package com.noop.ui import androidx.annotation.StringRes import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.tween @@ -273,6 +279,31 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { val updateStore = remember { UpdateStore.from(context) } var showUpdatesInbox by remember { mutableStateOf(false) } + // #86 (prototype): Instagram-style scroll-reactive bottom bar. It recedes (subtle shrink + fade + + // slide) when you scroll DOWN into content and returns when you scroll UP — driven by a single + // NestedScrollConnection on the NavHost, so every screen gets it with no per-screen wiring. Reduce + // Motion keeps the bar fully shown (no animation). The bar keeps its reserved layout space (Scaffold + // measured it full-size), so only a cheap graphicsLayer transform changes per frame — no relayout. + val reduceMotion = rememberReduceMotion() + var barCollapsed by remember { mutableStateOf(false) } + val barNestedScroll = remember(reduceMotion) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!reduceMotion) { + // available.y < 0 = content moving up (finger swipes up, reading down) → collapse. + if (available.y < -3f) barCollapsed = true + else if (available.y > 3f) barCollapsed = false + } + return Offset.Zero + } + } + } + val barCollapse by animateFloatAsState( + targetValue = if (barCollapsed && !reduceMotion) 1f else 0f, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), + label = "barCollapse", + ) + run { Scaffold( containerColor = Palette.surfaceBase, @@ -284,6 +315,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { // destination is lost without the drawer. GlassBottomBar( current = current, + collapse = barCollapse, // #86: scroll-reactive recede onTabSelected = { dest -> if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) }, @@ -293,7 +325,8 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { NavHost( navController = nav, startDestination = Destination.Today.route, - modifier = Modifier.padding(inner), + // #86: observe scroll direction here (one place → every screen) to drive the bar recede. + modifier = Modifier.padding(inner).nestedScroll(barNestedScroll), // README motion: top-level destinations crossfade (~240ms) on the calm, // decelerating global easing — nothing slides or bounces between tabs. The // same fade is used for back (pop) so the bar never feels jerky. Drill-ins @@ -695,11 +728,24 @@ private val barTrailingTabs = listOf( private fun GlassBottomBar( current: Destination, onTabSelected: (Destination) -> Unit, + // #86 (prototype): 0 = fully shown, 1 = collapsed (scrolled into content). A cheap GPU transform — + // subtle shrink + fade + slide toward the bottom edge — kept partial so the tabs stay reachable. + collapse: Float = 0f, ) { val barShape = RoundedCornerShape(50) Box( modifier = Modifier .fillMaxWidth() + // #86: recede on scroll. graphicsLayer only (no relayout); pivots at the bottom-centre so it + // shrinks toward the edge like the Instagram bar. Subtle by design (scale ~0.88, fade to 0.6). + .graphicsLayer { + val s = 1f - collapse * 0.12f + scaleX = s + scaleY = s + alpha = 1f - collapse * 0.40f + translationY = collapse * 22.dp.toPx() + transformOrigin = TransformOrigin(0.5f, 1f) + } // Clear the gesture-nav bar (home indicator) first, then add breathing room so the capsule // floats free of the bottom edge rather than jamming against it — iOS clears the home-indicator // safe area + 4pt; here navigationBarsPadding + 12dp gives the same lift. From 2456c794b505e98cc5d520e697fc5a8e0f899643 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 00:06:32 +1200 Subject: [PATCH 2/4] =?UTF-8?q?PROTOTYPE:=20retune=20the=20scroll-reactive?= =?UTF-8?q?=20bar=20=E2=80=94=20fade=20in=20place,=20don't=20slide=20off?= =?UTF-8?q?=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback: the collapsed bar slid too far down (into the gesture-nav area) and didn't read as transparent. Retuned: fade toward see-through (alpha to ~0.35), gentler shrink (0.92), and only a hair of drift (22dp -> 6dp) so it recedes IN PLACE rather than falling off the bottom. Android compiles. --- android/app/src/main/java/com/noop/ui/AppRoot.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index 822c45432..21ad2ef17 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -736,14 +736,16 @@ private fun GlassBottomBar( Box( modifier = Modifier .fillMaxWidth() - // #86: recede on scroll. graphicsLayer only (no relayout); pivots at the bottom-centre so it - // shrinks toward the edge like the Instagram bar. Subtle by design (scale ~0.88, fade to 0.6). + // #86: recede on scroll. graphicsLayer only (no relayout). It fades toward TRANSPARENT and + // shrinks slightly IN PLACE rather than sliding off the bottom — a big translate looked like the + // bar was falling off / going under the gesture nav. Gentle shrink (0.92), fades to ~0.35 (clearly + // see-through), and only a hair of downward drift (6dp). Bottom-centre pivot. .graphicsLayer { - val s = 1f - collapse * 0.12f + val s = 1f - collapse * 0.08f scaleX = s scaleY = s - alpha = 1f - collapse * 0.40f - translationY = collapse * 22.dp.toPx() + alpha = 1f - collapse * 0.65f + translationY = collapse * 6.dp.toPx() transformOrigin = TransformOrigin(0.5f, 1f) } // Clear the gesture-nav bar (home indicator) first, then add breathing room so the capsule From 2340ca39ee8a7619797b8b899654f91147784799 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 00:23:47 +1200 Subject: [PATCH 3/4] =?UTF-8?q?PROTOTYPE:=20make=20the=20bottom=20bar=20pi?= =?UTF-8?q?ll=20transparent=20=E2=80=94=20no=20fill=20behind=20the=20tabs?= =?UTF-8?q?=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback (correctly, twice): the pill's dark translucent fill read as a solid "bar" behind the tabs and blocked the content. Drop the fill entirely — the pill is now just the hairline capsule outline + tab icons floating over the content, which shows straight through. Also removed the drop shadow (also read as a slab). Android compiles. --- android/app/src/main/java/com/noop/ui/AppRoot.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index 21ad2ef17..81f92b8c8 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -758,12 +758,12 @@ private fun GlassBottomBar( ) { Surface( shape = barShape, - // "Glass": a translucent raised surface — a frosted island, not a hard slab. Compose has no - // cheap blur, so translucency (≈0.80) + a hairline rim is the Liquid-Glass stand-in. A soft, - // low drop shadow reads as floating without a glow. - color = Palette.surfaceRaised.copy(alpha = 0.80f), - tonalElevation = 2.dp, - shadowElevation = 4.dp, + // #86: NO fill. The dark translucent fill read as a solid "bar" behind the tabs; the content + // should show straight through. Keep the pill SHAPE via the hairline outline only (a floating + // capsule of tabs over the content), no fill and no drop shadow (which also read as a slab). + color = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp, modifier = Modifier .fillMaxWidth() // Cap the width so the pill stays a centred floating island on tablets, not a full-bleed bar. From 6e7e496620b1ccb4b2ce1f25a1efea6ee91c7356 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Thu, 9 Jul 2026 00:50:41 +1200 Subject: [PATCH 4/4] =?UTF-8?q?PROTOTYPE:=20float=20the=20tab=20bar=20over?= =?UTF-8?q?=20content=20=E2=80=94=20removes=20the=20reserved=20"bar"=20ban?= =?UTF-8?q?d=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-width band behind the pill was the Scaffold's RESERVED bottomBar region (filled with the flat app background), sitting below the tinted content. Moved the bar out of the bottomBar slot and overlay it (Box + align bottom-centre) on full-bleed content, so the content's background fills continuously behind the now-transparent pill — no band. NestedScroll moves to the full-bleed NavHost. Note: content now scrolls UNDER the floating transparent pill (iOS-style); screens keep their 28dp bottom padding, so the last item can sit partly behind the bar at the very bottom of a scroll — a bottom-padding bump is the follow-up if that bites. Android compiles. --- .../app/src/main/java/com/noop/ui/AppRoot.kt | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt index 81f92b8c8..e85343124 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -307,26 +307,17 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { run { Scaffold( containerColor = Palette.surfaceBase, - bottomBar = { - // One unified "glass" bar: four evenly-spaced tabs — Today · Trends · Sleep · More - // (matches the iOS FloatingTabBar). The quick-action "+" lives in the Today header's - // top-right (balancing the avatar), so the bar is clean tabs only. "More" navigates to - // its own page (mirroring the iOS More tab) that reaches every grouped destination, so no - // destination is lost without the drawer. - GlassBottomBar( - current = current, - collapse = barCollapse, // #86: scroll-reactive recede - onTabSelected = { dest -> - if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) - }, - ) - }, + // #86: the tab bar is NO LONGER a reserved bottomBar slot (that reserved region, filled with the + // flat app background, was the "bar" band behind the pill). It's overlaid in the Box below so the + // content's background fills continuously behind the floating pill. ) { inner -> + Box(modifier = Modifier.padding(inner).fillMaxSize()) { NavHost( navController = nav, startDestination = Destination.Today.route, - // #86: observe scroll direction here (one place → every screen) to drive the bar recede. - modifier = Modifier.padding(inner).nestedScroll(barNestedScroll), + // #86: full-bleed so content (and its background) fills behind the floating bar. The scroll + // direction is observed here (one place → every screen) to drive the bar recede. + modifier = Modifier.fillMaxSize().nestedScroll(barNestedScroll), // README motion: top-level destinations crossfade (~240ms) on the calm, // decelerating global easing — nothing slides or bounces between tabs. The // same fade is used for back (pop) so the bar never feels jerky. Drill-ins @@ -468,6 +459,17 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { MoreScreen(onNavigate = { nav.navigateTopLevel(it) }) } } + // #86: the floating tab bar, overlaid on the content (not a reserved slot) and aligned to the + // bottom — so the content's background fills continuously behind the transparent pill. + GlassBottomBar( + current = current, + collapse = barCollapse, // #86: scroll-reactive recede + onTabSelected = { dest -> + if (dest.route != currentRoute) nav.navigateTopLevel(dest.route) + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } // Quick-actions sheet, opened by the raised gold centre FAB. Each row routes to an @@ -731,10 +733,13 @@ private fun GlassBottomBar( // #86 (prototype): 0 = fully shown, 1 = collapsed (scrolled into content). A cheap GPU transform — // subtle shrink + fade + slide toward the bottom edge — kept partial so the tabs stay reachable. collapse: Float = 0f, + // #86: the bar now FLOATS over the content (overlaid, not a reserved Scaffold slot), so the content's + // background fills continuously behind it — no reserved "bar" band. The caller aligns it bottom-centre. + modifier: Modifier = Modifier, ) { val barShape = RoundedCornerShape(50) Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() // #86: recede on scroll. graphicsLayer only (no relayout). It fades toward TRANSPARENT and // shrinks slightly IN PLACE rather than sliding off the bottom — a big translate looked like the