From cf30222debfca0adb7f93dfcbda22411f611a152 Mon Sep 17 00:00:00 2001 From: Quai Date: Mon, 13 Jul 2026 02:16:13 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Scroll-reactive=20bottom=20navigation=20?= =?UTF-8?q?=E2=80=94=20hybrid=20collapse=20with=20Reactive=20/=20Expanded?= =?UTF-8?q?=20/=20Compact=20setting=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../Sources/StrandDesign/Motion.swift | 9 ++ Strand/Liquid/LiquidTodayView.swift | 25 +++- Strand/Screens/ScreenScaffold.swift | 73 ++++++++++ Strand/Screens/SettingsView.swift | 21 +++ StrandiOS/App/RootTabView.swift | 137 +++++++++++++++--- .../app/src/main/java/com/noop/ui/AppRoot.kt | 130 +++++++++++++---- .../src/main/java/com/noop/ui/Components.kt | 11 +- .../src/main/java/com/noop/ui/MainActivity.kt | 1 + .../main/java/com/noop/ui/SettingsScreen.kt | 18 +++ .../app/src/main/java/com/noop/ui/Theme.kt | 3 + 10 files changed, 378 insertions(+), 50 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift index b56a263120..34db735179 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Motion.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Motion.swift @@ -30,6 +30,9 @@ public enum StrandMotion { /// Slow / draw-in (ring arc, waveform ignite). public static let durationSlow: Double = 0.9 + /// Deliberately slower restoration for scroll-reactive navigation — the bar returns calmly rather than popping in. + public static let durationNavigationExpand: Double = 0.42 + /// One breath cycle for ambient pulsing (bloom, listening flatline). public static let breathPeriod: Double = 3.2 @@ -63,6 +66,12 @@ public enum StrandMotion { /// Standard fade. public static let fade = Animation.easeInOut(duration: durationStandard) + + /// Smooth, non-bouncy contraction after scroll-reactive navigation crosses its compact threshold. + public static let navigationBarCollapse = Animation.easeInOut(duration: durationStandard) + + /// A deliberately slower restoration so the full bar returns calmly instead of appearing to pop in. + public static let navigationBarExpand = Animation.easeInOut(duration: durationNavigationExpand) } #if DEBUG diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 46e5d8e5aa..a3d8f9de1e 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -21,6 +21,10 @@ struct LiquidTodayView: View { @EnvironmentObject var router: NavRouter @EnvironmentObject var profile: ProfileStore @Environment(\.accessibilityReduceMotion) private var reduceMotion + #if os(iOS) + @Environment(\.tabBarScrollTag) private var tabBarScrollTag + @Environment(\.tabBarScrollInteractionChanged) private var tabBarScrollInteractionChanged + #endif /// Shared with the real Today's card-customise editor so the two stay in sync. @AppStorage(DashboardCardPrefs.selectionKey) private var dashboardCardsRaw = "" @@ -191,8 +195,16 @@ struct LiquidTodayView: View { // Scroll-offset probe at the very top (before padding), so its minY in the scroll's // coordinate space reads the top OVERSCROLL: ~0 at rest, positive as you pull down. GeometryReader { g in + let offset = g.frame(in: .named(Self.pullSpace)).minY + #if os(iOS) + Color.clear + .preference(key: PullOffsetKey.self, value: offset) + .preference(key: TabBarScrollOffsetKey.self, + value: tabBarScrollTag.map { [$0: offset] } ?? [:]) + #else Color.clear.preference(key: PullOffsetKey.self, - value: g.frame(in: .named(Self.pullSpace)).minY) + value: offset) + #endif } .frame(height: 0) @@ -220,7 +232,16 @@ struct LiquidTodayView: View { #endif } .coordinateSpace(name: Self.pullSpace) - .onPreferenceChange(PullOffsetKey.self) { handlePull($0) } + #if os(iOS) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in tabBarScrollInteractionChanged(true) } + .onEnded { _ in tabBarScrollInteractionChanged(false) } + ) + #endif + .onPreferenceChange(PullOffsetKey.self) { offset in + handlePull(offset) + } // The sky is a FIXED full-bleed backdrop drawn behind the scroll content, edge-to-edge under the // status bar. A ScrollView background does not scroll with the content, so pulling down never // moves the sky (the exact behaviour the scaffold uses on the classic Today). diff --git a/Strand/Screens/ScreenScaffold.swift b/Strand/Screens/ScreenScaffold.swift index 5d1a7ef134..31ab759820 100644 --- a/Strand/Screens/ScreenScaffold.swift +++ b/Strand/Screens/ScreenScaffold.swift @@ -1,6 +1,59 @@ import SwiftUI import StrandDesign +#if os(iOS) +enum BottomBarBehavior: String, CaseIterable, Identifiable { + static let storageKey = "noop.bottomBarBehavior" + + case reactive + case expanded + case compact + + var id: String { rawValue } + var label: LocalizedStringKey { + switch self { + case .reactive: "Reactive" + case .expanded: "Expanded" + case .compact: "Compact" + } + } +} + +private struct TabBarScrollTagKey: EnvironmentKey { + static let defaultValue: Int? = nil +} + +private struct TabBarScrollInteractionChangedKey: EnvironmentKey { + static let defaultValue: (Bool) -> Void = { _ in } +} + +extension EnvironmentValues { + var tabBarScrollTag: Int? { + get { self[TabBarScrollTagKey.self] } + set { self[TabBarScrollTagKey.self] = newValue } + } + + var tabBarScrollInteractionChanged: (Bool) -> Void { + get { self[TabBarScrollInteractionChangedKey.self] } + set { self[TabBarScrollInteractionChangedKey.self] = newValue } + } +} + +/// Scrollable tab roots publish their content offset to the iPhone shell, which owns the floating bar. +/// Values stay keyed by tab because `TabView` retains its off-screen roots; reducing them to one scalar lets +/// a hidden tab's zero overwrite the visible tab's moving offset. +struct TabBarScrollOffsetKey: PreferenceKey { + static var defaultValue: [Int: CGFloat] = [:] + static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +private enum TabBarScrollSpace { + static let name = "noop.tabBarScroll" +} +#endif + /// Standard scrollable screen container: title + dark surface + content column. struct ScreenScaffold: View { /// Optional — when nil (and no subtitle) the header is omitted entirely, so a screen can supply its @@ -31,6 +84,8 @@ struct ScreenScaffold: View { // by `#if os(iOS)` — a runtime size-class check alone would also narrow the Mac detail pane. #if os(iOS) @Environment(\.horizontalSizeClass) private var hSizeClass + @Environment(\.tabBarScrollTag) private var tabBarScrollTag + @Environment(\.tabBarScrollInteractionChanged) private var tabBarScrollInteractionChanged #endif var body: some View { @@ -49,6 +104,18 @@ struct ScreenScaffold: View { .frame(maxWidth: hSizeClass == .regular ? 700 : .infinity, alignment: hSizeClass == .regular ? .center : .leading) .frame(maxWidth: .infinity, alignment: .center) + // Read the content's real position in its scroll viewport. The root shell accumulates these + // offsets before changing state, so bounce and one-pixel settling never flicker the tab bar. + .background { + GeometryReader { geometry in + Color.clear.preference( + key: TabBarScrollOffsetKey.self, + value: tabBarScrollTag.map { + [$0: geometry.frame(in: .named(TabBarScrollSpace.name)).minY] + } ?? [:] + ) + } + } #else .padding(28) .frame(maxWidth: .infinity, alignment: .leading) @@ -59,6 +126,12 @@ struct ScreenScaffold: View { // permits horizontal bounce when content genuinely overflows the width (it does not here, the column // is width-capped), so the spurious horizontal rubber-band that caused the sideways drift is gone. .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .coordinateSpace(name: TabBarScrollSpace.name) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in tabBarScrollInteractionChanged(true) } + .onEnded { _ in tabBarScrollInteractionChanged(false) } + ) #endif // The flat canvas, plus an optional full-bleed TOP backdrop (Today's day-cycle scene) drawn behind // the scroll content — edge-to-edge under the status bar. The scene is CONFINED to the header+hero diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 34ee3c38d0..09e055e436 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -72,6 +72,10 @@ struct SettingsView: View { @AppStorage("appIcon.alt") private var useNavyIcon = false // Light/Dark/System theme. Read by both app roots' .preferredColorScheme; default follows the OS. @AppStorage(AppearanceMode.storageKey) private var appearanceRaw = AppearanceMode.system.rawValue + #if os(iOS) + // iPhone floating navigation: react to scroll by default, or pin either resting state. + @AppStorage(BottomBarBehavior.storageKey) private var bottomBarBehaviorRaw = BottomBarBehavior.reactive.rawValue + #endif // Chart colour style: Titanium (brand) or Classic (throwback red→green). Re-colours gauges + charts. @AppStorage(ChartStyle.storageKey) private var chartStyleRaw = ChartStyle.titanium.rawValue // Day-cycle scene backdrop behind Today (#698). Default ON. Off swaps the scene for a plain dark @@ -670,6 +674,23 @@ struct SettingsView: View { .accessibilityLabel("Chart colours") } #if os(iOS) + FormRow(label: "Bottom bar") { + Picker("Bottom bar", selection: $bottomBarBehaviorRaw) { + ForEach(BottomBarBehavior.allCases) { behavior in + Text(behavior.label).tag(behavior.rawValue) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel("Bottom bar") + } + Text("Reactive subtly compacts while scrolling. Expanded and Compact keep the chosen size at all times.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + FormRow(label: "App icon") { Picker("App icon", selection: $useNavyIcon) { Text("Default").tag(false) diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 8eefee53d2..328572623c 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -10,6 +10,7 @@ struct RootTabView: View { /// Cross-screen navigation requests (e.g. Live → "Manage devices"). Devices isn't a tab — it lives /// behind the More list — so a request presents it as a sheet, matching the quick-action screens. @EnvironmentObject private var router: NavRouter + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Which quick-action screen the centre FAB is presenting (nil = sheet closed). @State private var quickAction: QuickAction? @@ -21,6 +22,12 @@ struct RootTabView: View { /// Selected tab — bound so tab switches can crossfade (README §Motion: ~240ms opacity swap /// between tab roots, calm easing). Defaults to Today. @State private var selectedTab: Int = 0 + /// A small scroll-linked pre-compression, followed by a fast eased settle once the threshold is crossed. + @State private var tabBarCollapseProgress: CGFloat = 0 + @State private var tabBarDownwardScroll: CGFloat = 0 + @State private var tabBarIsCollapsed = false + @State private var tabBarScrollIsDragging = false + @State private var lastTabBarScrollOffset: CGFloat? /// Which More-tab groups are expanded (S2). Insights + Body stay open at rest; Data + App collapse to /// just their header until tapped. Persisted (#860 item 2): the user's open/closed choice must SURVIVE /// leaving and re-entering the More tab (and relaunch), not reset to the seed every visit. Backed by an @@ -32,6 +39,11 @@ struct RootTabView: View { /// V8 liquid redesign is the default Today; the Settings toggle lets a user fall back to the classic /// Today if they prefer it (keyed identically to the SettingsView toggle). Default ON. @AppStorage("noop.liquidTodayEnabled") private var liquidTodayEnabled = true + @AppStorage(BottomBarBehavior.storageKey) private var bottomBarBehaviorRaw = BottomBarBehavior.reactive.rawValue + + private var bottomBarBehavior: BottomBarBehavior { + BottomBarBehavior(rawValue: bottomBarBehaviorRaw) ?? .reactive + } /// The Today tab root, honouring the liquid/classic preference. @ViewBuilder private var todayTabRoot: some View { @@ -61,10 +73,18 @@ struct RootTabView: View { // cleanly in the gap between them — replaces the native tab bar: no overlap, no glow. The // native TabView still drives content + per-tab nav state; only its bar is hidden. TabView(selection: $selectedTab) { - tab(todayTabRoot, "Today", "square.grid.2x2").tag(0) - tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis").tag(1) - tab(SleepView(), "Sleep", "bed.double").tag(2) - moreTab.tag(3) + tab(todayTabRoot, "Today", "square.grid.2x2", tag: 0).tag(0) + tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis", tag: 1).tag(1) + tab(SleepView(), "Sleep", "bed.double", tag: 2).tag(2) + moreTab + .environment(\.tabBarScrollTag, 3) + .environment(\.tabBarScrollInteractionChanged) { isDragging in + if selectedTab == 3 { tabBarScrollIsDragging = isDragging } + } + .onPreferenceChange(TabBarScrollOffsetKey.self) { offsets in + if selectedTab == 3, let offset = offsets[3] { handleTabBarScroll(offset) } + } + .tag(3) } .tint(StrandPalette.accent) .toolbar(.hidden, for: .tabBar) @@ -87,7 +107,10 @@ struct RootTabView: View { } ) - FloatingTabBar(selection: $selectedTab, onReselect: { _ in + FloatingTabBar(selection: $selectedTab, + collapseProgress: bottomBarBehavior == .compact ? 1 : + (bottomBarBehavior == .expanded ? 0 : tabBarCollapseProgress), + onReselect: { _ in // Re-tapping the active tab refreshes that page's data (2026-07-02). Task { await repo.refresh() } }) @@ -153,6 +176,63 @@ struct RootTabView: View { router.quickActionsRequested = false } } + .onChange(of: selectedTab) { _, _ in + resetTabBarScroll(expand: true) + } + .onChange(of: bottomBarBehaviorRaw) { _, _ in + resetTabBarScroll(expand: true) + } + } + + /// Instagram-style hybrid: the first downward travel subtly pre-compresses with the finger; crossing + /// the threshold eases the rest of the way to compact. The first upward sample expands immediately. + private func handleTabBarScroll(_ offset: CGFloat) { + guard bottomBarBehavior == .reactive else { return } + guard let previous = lastTabBarScrollOffset else { + lastTabBarScrollOffset = offset + return + } + + let delta = offset - previous + lastTabBarScrollOffset = offset + guard abs(delta) >= NoopMetrics.space1 / 8 else { return } + + if delta > 0 { + // Elastic bounce, refresh completion and content relayout can all move the offset downward. + // Only a finger-driven upward scroll is allowed to restore the expanded navigation bar. + guard tabBarScrollIsDragging else { return } + tabBarDownwardScroll = 0 + guard tabBarCollapseProgress > 0 else { return } + tabBarIsCollapsed = false + withAnimation(NoopMotion.gated(StrandMotion.navigationBarExpand, reduced: reduceMotion)) { + tabBarCollapseProgress = 0 + } + return + } + + guard !tabBarIsCollapsed else { return } + tabBarDownwardScroll += -delta + let threshold = NoopMetrics.space6 + let precompression: CGFloat = 0.16 + tabBarCollapseProgress = min( + precompression, + precompression * tabBarDownwardScroll / threshold + ) + + if tabBarDownwardScroll >= threshold { + tabBarIsCollapsed = true + withAnimation(NoopMotion.gated(StrandMotion.navigationBarCollapse, reduced: reduceMotion)) { + tabBarCollapseProgress = 1 + } + } + } + + private func resetTabBarScroll(expand: Bool) { + lastTabBarScrollOffset = nil + tabBarDownwardScroll = 0 + tabBarIsCollapsed = false + tabBarScrollIsDragging = false + if expand { tabBarCollapseProgress = 0 } } /// A routed v5 pillar screen wrapped in its own nav stack + Done button (mirrors `quickScreen`). @@ -264,7 +344,8 @@ struct RootTabView: View { } } - private func tab(_ view: V, _ title: LocalizedStringKey, _ icon: String) -> some View { + private func tab(_ view: V, _ title: LocalizedStringKey, _ icon: String, + tag: Int) -> some View { // Each primary tab gets its OWN NavigationStack so the in-content NavigationLinks (e.g. the Today // dashboard card rows) both navigate AND render opaque. An ORPHANED NavigationLink (no // NavigationStack ancestor) renders its whole label in a disabled/translucent state — that was @@ -276,6 +357,15 @@ struct RootTabView: View { .background(StrandPalette.surfaceBase.ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) } + .environment(\.tabBarScrollTag, tag) + .environment(\.tabBarScrollInteractionChanged) { isDragging in + if selectedTab == tag { tabBarScrollIsDragging = isDragging } + } + // TabView retains off-screen roots. Filter their geometry updates here so a hidden tab's layout + // cannot be mistaken for a scroll on the visible tab. + .onPreferenceChange(TabBarScrollOffsetKey.self) { offsets in + if selectedTab == tag, let offset = offsets[tag] { handleTabBarScroll(offset) } + } .toolbar(.hidden, for: .tabBar) // we draw our own FloatingTabBar .tabItem { Label(title, systemImage: icon) } } @@ -547,8 +637,11 @@ private struct QuickActionSheet: View { /// Glass where available, a `.ultraThinMaterial` fallback below. Replaces the hidden native tab bar. private struct FloatingTabBar: View { @Binding var selection: Int + /// 0 is expanded and 1 is compact. Intermediate values remain visible while the scroll is in between. + var collapseProgress: CGFloat = 0 /// Fires when the user taps the ALREADY-active tab (2026-07-02: re-tap should refresh). var onReselect: (Int) -> Void = { _ in } + @Environment(\.accessibilityReduceMotion) private var reduceMotion private struct Item: Identifiable { let title: LocalizedStringKey; let icon: String; let tag: Int; var id: Int { tag } } private let nav = [Item(title: "Today", icon: "square.grid.2x2", tag: 0), @@ -557,16 +650,19 @@ private struct FloatingTabBar: View { Item(title: "More", icon: "ellipsis", tag: 3)] var body: some View { + let progress = min(1, max(0, collapseProgress)) + // Reduce Motion retains the useful compact state but removes continuous interpolation. + let collapse = reduceMotion ? (progress >= 0.5 ? 1.0 : 0.0) : progress // One frosted glass bar, four evenly-spaced tabs. The quick-action "+" now lives in the // top-right of each screen's header (balancing the profile avatar on the left). - HStack(spacing: 2) { - tabButton(nav[0]) - tabButton(nav[1]) - tabButton(nav[2]) - tabButton(nav[3]) + HStack(spacing: NoopMetrics.space1 / 2) { + tabButton(nav[0], collapse: collapse) + tabButton(nav[1], collapse: collapse) + tabButton(nav[2], collapse: collapse) + tabButton(nav[3], collapse: collapse) } - .padding(.vertical, 7) - .padding(.horizontal, 8) + .padding(.vertical, NoopMetrics.space2 - collapse * NoopMetrics.space1) + .padding(.horizontal, NoopMetrics.space2) .liquidGlass(in: Capsule()) // Over the liquid Today the sky ends at ~340pt, so the bar floats on flat opaque surfaceBase — // a blur material has nothing to dissolve and hardens into a solid lozenge (2026-07-02: @@ -582,11 +678,11 @@ private struct FloatingTabBar: View { ) // Lighter, wider shadow: real elevation without stamping a dark halo on the flat canvas. .shadow(color: .black.opacity(0.22), radius: 18, x: 0, y: 8) - .padding(.horizontal, 22) - .padding(.bottom, 4) + .padding(.horizontal, NoopMetrics.space6 + collapse * NoopMetrics.space5) + .padding(.bottom, NoopMetrics.space1) } - private func tabButton(_ item: Item) -> some View { + private func tabButton(_ item: Item, collapse: CGFloat) -> some View { let active = selection == item.tag return Button { if active { @@ -595,15 +691,18 @@ private struct FloatingTabBar: View { withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selection = item.tag } } } label: { - VStack(spacing: 3) { + VStack(spacing: NoopMetrics.space1 * (1 - collapse)) { Image(systemName: item.icon) .font(.system(size: 18, weight: active ? .semibold : .regular)) Text(item.title) .font(.system(size: 10, weight: active ? .semibold : .medium)) + .frame(height: NoopMetrics.space3 * (1 - collapse)) + .opacity(1 - collapse) + .clipped() } .foregroundStyle(active ? StrandPalette.accent : StrandPalette.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 3) + .frame(maxWidth: .infinity, minHeight: NoopMetrics.controlHeight) + .padding(.vertical, NoopMetrics.space1 * (1 - collapse)) .contentShape(Rectangle()) } .buttonStyle(.plain) 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 e853431240..281e17d472 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -3,7 +3,7 @@ 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.animation.core.snap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -107,6 +107,36 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +enum class BottomBarBehavior(val storageValue: String, val label: String) { + REACTIVE("reactive", "Reactive"), + EXPANDED("expanded", "Expanded"), + COMPACT("compact", "Compact"); + + companion object { + fun fromStorage(raw: String?): BottomBarBehavior = + entries.firstOrNull { it.storageValue == raw } ?: REACTIVE + } +} + +/** Persisted bottom-navigation behavior, mirrored in snapshot state so Settings updates the shell live. */ +object BottomBarPrefs { + const val KEY = "noop.bottomBarBehavior" + + var behavior by mutableStateOf(BottomBarBehavior.REACTIVE) + private set + + fun load(context: android.content.Context) { + behavior = BottomBarBehavior.fromStorage( + NoopPrefs.of(context).getString(KEY, BottomBarBehavior.REACTIVE.storageValue), + ) + } + + fun set(context: android.content.Context, value: BottomBarBehavior) { + behavior = value + NoopPrefs.of(context).edit().putString(KEY, value.storageValue).apply() + } +} + // MARK: - Navigation model // // The macOS app's sidebar holds many sections; on Android (mirroring the iOS RootTabView) we surface @@ -279,30 +309,72 @@ 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. + // #86: Instagram-style hybrid: slight scroll-linked pre-compression, then a fast eased settle after a + // threshold. The first upward scroll expands it. Reduce Motion preserves state but snaps rendering. val reduceMotion = rememberReduceMotion() + val bottomBarBehavior = BottomBarPrefs.behavior + var barPrecompression by remember { mutableStateOf(0f) } var barCollapsed by remember { mutableStateOf(false) } - val barNestedScroll = remember(reduceMotion) { + val density = androidx.compose.ui.platform.LocalDensity.current + val barScrollNoisePx = with(density) { Metrics.space4.toPx() / 8f } + val barCollapseThresholdPx = with(density) { Metrics.space24.toPx() } + val barNestedScroll = remember( + currentRoute, + bottomBarBehavior, + barScrollNoisePx, + barCollapseThresholdPx, + ) { object : NestedScrollConnection { + private var downwardScroll = 0f + 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 + if (bottomBarBehavior != BottomBarBehavior.REACTIVE) return Offset.Zero + val delta = available.y + if (kotlin.math.abs(delta) >= barScrollNoisePx) { + // Ignore positive rebound/settling deltas: only an active user gesture restores the bar. + if (delta > 0f && source == NestedScrollSource.UserInput) { + downwardScroll = 0f + barPrecompression = 0f + barCollapsed = false + } else if (!barCollapsed) { + downwardScroll += -delta + barPrecompression = + (0.16f * downwardScroll / barCollapseThresholdPx).coerceIn(0f, 0.16f) + if (downwardScroll >= barCollapseThresholdPx) barCollapsed = true + } } return Offset.Zero } } } - val barCollapse by animateFloatAsState( - targetValue = if (barCollapsed && !reduceMotion) 1f else 0f, - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), - label = "barCollapse", + LaunchedEffect(currentRoute, bottomBarBehavior) { + barPrecompression = 0f + barCollapsed = false + } + val effectiveBarCollapsed = bottomBarBehavior == BottomBarBehavior.COMPACT || + (bottomBarBehavior == BottomBarBehavior.REACTIVE && barCollapsed) + val barSettledCollapse by animateFloatAsState( + targetValue = if (effectiveBarCollapsed) 1f else 0f, + animationSpec = if (reduceMotion) { + snap() + } else if (effectiveBarCollapsed) { + barCollapseSpec + } else { + barExpandSpec + }, + label = "barSettledCollapse", ) + val barCollapse = if (reduceMotion) { + if (effectiveBarCollapsed) 1f else 0f + } else if (bottomBarBehavior != BottomBarBehavior.REACTIVE) { + barSettledCollapse + } else if (barCollapsed) { + maxOf(barPrecompression, barSettledCollapse) + } else if (barPrecompression > 0f) { + barPrecompression + } else { + barSettledCollapse + } run { Scaffold( @@ -730,8 +802,7 @@ 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. + // #86: 0 = fully shown, 1 = compact (scrolled into content). 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. @@ -741,16 +812,12 @@ private fun GlassBottomBar( Box( 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 - // 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. + // Contract from the left, top and right while the bottom edge stays planted above gesture nav. + // The transform is GPU-only; the Scaffold never reflows the scrolling content. .graphicsLayer { val s = 1f - collapse * 0.08f scaleX = s scaleY = s - 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 @@ -763,10 +830,9 @@ private fun GlassBottomBar( ) { Surface( shape = barShape, - // #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, + // A light translucent scrim preserves icon contrast over charts and bright cards while still + // letting the full-bleed content read through the floating capsule. + color = Palette.surfaceRaised.copy(alpha = StrandAlpha.navigationGlass), tonalElevation = 0.dp, shadowElevation = 0.dp, modifier = Modifier @@ -787,6 +853,7 @@ private fun GlassBottomBar( icon = tab.icon, label = stringResource(tab.labelRes), active = current == tab.dest, + collapse = collapse, modifier = Modifier.weight(1f), onClick = { onTabSelected(tab.dest) }, ) @@ -796,6 +863,7 @@ private fun GlassBottomBar( icon = tab.icon, label = stringResource(tab.labelRes), active = current == tab.dest, + collapse = collapse, modifier = Modifier.weight(1f), onClick = { onTabSelected(tab.dest) }, ) @@ -808,6 +876,7 @@ private fun GlassBottomBar( // into any grouped destination still reads as "you're in More", never "nowhere". active = current != Destination.Today && current != Destination.Trends && current != Destination.Sleep, + collapse = collapse, modifier = Modifier.weight(1f), onClick = { onTabSelected(Destination.More) }, ) @@ -823,6 +892,7 @@ private fun BarSlot( icon: ImageVector, label: String, active: Boolean, + collapse: Float = 0f, modifier: Modifier = Modifier, onClick: () -> Unit, ) { @@ -848,6 +918,7 @@ private fun BarSlot( fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium, ), color = tint, + modifier = Modifier.graphicsLayer { alpha = 1f - collapse }, ) } } @@ -873,6 +944,11 @@ private val quickActions: List = listOf( /** The calm global easing curve from the handoff (cubic-bezier 0.22, 1, 0.36, 1). */ private val NavEasing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) +/** Symmetric, non-bouncy navigation transitions; restoration is deliberately slower than contraction. */ +private val BarSettleEasing = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) +private val barCollapseSpec = tween(durationMillis = 300, easing = BarSettleEasing) +private val barExpandSpec = tween(durationMillis = 420, easing = BarSettleEasing) + /** ~240ms crossfade on the calm easing — the README "Tab crossfade" between roots. */ private val navFadeSpec = tween(durationMillis = 240, easing = NavEasing) diff --git a/android/app/src/main/java/com/noop/ui/Components.kt b/android/app/src/main/java/com/noop/ui/Components.kt index ebd0f21131..4a51266e56 100644 --- a/android/app/src/main/java/com/noop/ui/Components.kt +++ b/android/app/src/main/java/com/noop/ui/Components.kt @@ -1155,7 +1155,9 @@ fun ScreenScaffold( Column( modifier = columnModifier .verticalScroll(rememberScrollState()) - .padding(start = 28.dp, end = 28.dp, top = topPadding, bottom = 28.dp), + // The app bar floats over content, so the final row needs enough scroll room to clear it. + .padding(start = 28.dp, end = 28.dp, top = topPadding, + bottom = Metrics.tabBarClearance), // #765: one shared inter-card spacing token (was a bare `20.dp`), so the eager + lazy scaffolds // and every screen through them keep the SAME uniform gap between top-level cards. verticalArrangement = Arrangement.spacedBy(Metrics.screenRowSpacing), @@ -1303,7 +1305,12 @@ fun LazyScreenScaffold( val list: @Composable () -> Unit = { LazyColumn( modifier = listModifier, - contentPadding = PaddingValues(start = 28.dp, top = topPadding, end = 28.dp, bottom = 28.dp), + contentPadding = PaddingValues( + start = 28.dp, + top = topPadding, + end = 28.dp, + bottom = Metrics.tabBarClearance, + ), // #765: the shared inter-card spacing token by default (Today/Explore + the eager screens share // one uniform card rhythm); a caller may pass a tighter [rowSpacing] (the liquid Today does, for // the iOS-compact section rhythm). diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index 8a56d0f3b5..936074ec97 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -91,6 +91,7 @@ class MainActivity : ComponentActivity() { // and chart ramps are correct from the very first frame (no flash). AppearancePrefs.load(this) ChartStylePrefs.load(this) + BottomBarPrefs.load(this) // Decode the optional on-device profile photo (if set) before first composition so the Today // header + Settings avatars show it from the first frame. No-op when no photo is set. ProfileAvatarStore.load(this) diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index aac614b6fb..9549120859 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -462,6 +462,8 @@ fun SettingsScreen( // Theme (System / Light / Dark) — drives NoopTheme; AppearancePrefs mirrors it in snapshot state. var themeMode by remember { mutableStateOf(AppearancePrefs.mode) } + // Floating bottom navigation: scroll-reactive by default, or pinned expanded/compact. + var bottomBarBehavior by remember { mutableStateOf(BottomBarPrefs.behavior) } // Chart colours (Titanium / Classic) — re-colours gauges + charts; ChartStylePrefs mirrors it live. var chartStyle by remember { mutableStateOf(ChartStylePrefs.style) } // Day-cycle background (#698) — the time-of-day scene behind Today. Default ON. SharedPreferences @@ -922,6 +924,22 @@ fun SettingsScreen( }, ) } + FormRow(label = "Bottom bar") { + SegmentedPillControl( + items = BottomBarBehavior.entries, + selection = bottomBarBehavior, + label = { it.label }, + onSelect = { behavior -> + bottomBarBehavior = behavior + BottomBarPrefs.set(context, behavior) + }, + ) + } + Text( + "Reactive subtly compacts while scrolling. Expanded and Compact keep the chosen size at all times.", + style = NoopType.footnote, + color = Palette.textTertiary, + ) // Day-cycle background (#698): the time-of-day scene behind Today. On by default. Off swaps it // for a plain dark canvas for people who find the moving scene distracting. Takes effect next diff --git a/android/app/src/main/java/com/noop/ui/Theme.kt b/android/app/src/main/java/com/noop/ui/Theme.kt index 29f24fc5be..fde35197c4 100644 --- a/android/app/src/main/java/com/noop/ui/Theme.kt +++ b/android/app/src/main/java/com/noop/ui/Theme.kt @@ -355,6 +355,7 @@ object StrandAlpha { const val unselectedBar = 0.88f const val warningFill = 0.12f const val warningBorder = 0.40f + const val navigationGlass = 0.42f } // MARK: - Metrics (ported from StrandDesign/Components.swift NoopMetrics) @@ -386,6 +387,8 @@ object Metrics { val screenPadding = 24.dp val tileHeight = 108.dp // every metric tile is this tall val chartHeight = 220.dp + // Extra scroll room so the final card clears the floating bottom navigation capsule. + val tabBarClearance = 76.dp val divider = 1.dp val compactChartHeight = chartHeight - 90.dp val selectorTopUp = sectionGap - screenRowSpacing From d6f6d37cbdfe542e3b94e0d2d44493e977ab4a1f Mon Sep 17 00:00:00 2001 From: Quai Date: Mon, 13 Jul 2026 02:33:11 +0200 Subject: [PATCH 2/3] Fix: preserve bar collapse state across tab/route switches (#86) Co-Authored-By: Claude Sonnet 4.6 --- StrandiOS/App/RootTabView.swift | 6 +++++- android/app/src/main/java/com/noop/ui/AppRoot.kt | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 328572623c..316ae7221a 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -177,7 +177,11 @@ struct RootTabView: View { } } .onChange(of: selectedTab) { _, _ in - resetTabBarScroll(expand: true) + // Keep the bar's current collapsed/expanded state — only reset scroll tracking so + // the new tab's offset doesn't inherit the previous tab's position. + lastTabBarScrollOffset = nil + tabBarDownwardScroll = 0 + tabBarScrollIsDragging = false } .onChange(of: bottomBarBehaviorRaw) { _, _ in resetTabBarScroll(expand: true) 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 281e17d472..32eade6ff2 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -347,7 +347,12 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { } } } - LaunchedEffect(currentRoute, bottomBarBehavior) { + // Route changes reset scroll tracking but preserve the bar's collapsed/expanded state. + // Behavior setting changes force an expand so the new mode starts from a known position. + LaunchedEffect(currentRoute) { + barPrecompression = 0f + } + LaunchedEffect(bottomBarBehavior) { barPrecompression = 0f barCollapsed = false } From 88e3a63a85e82260dc31abd2b28e8ee3595e8f2e Mon Sep 17 00:00:00 2001 From: Quai Date: Mon, 13 Jul 2026 03:14:39 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Fix=20NestedScrollSource.UserInput=20?= =?UTF-8?q?=E2=86=92=20Drag=20for=20Compose=20BOM=202024.06=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserInput was added in Compose 1.7 (BOM 2024.09+); Drag is the pre-1.7 equivalent for active user-gesture scrolls and compiles cleanly against the current BOM 2024.06.00. Co-Authored-By: Claude Sonnet 4.6 --- android/app/src/main/java/com/noop/ui/AppRoot.kt | 2 +- 1 file changed, 1 insertion(+), 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 32eade6ff2..f16e99fcf9 100644 --- a/android/app/src/main/java/com/noop/ui/AppRoot.kt +++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt @@ -332,7 +332,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { val delta = available.y if (kotlin.math.abs(delta) >= barScrollNoisePx) { // Ignore positive rebound/settling deltas: only an active user gesture restores the bar. - if (delta > 0f && source == NestedScrollSource.UserInput) { + if (delta > 0f && source == NestedScrollSource.Drag) { downwardScroll = 0f barPrecompression = 0f barCollapsed = false