Bottom bar: add frosted glass option (Settings > Appearance)

Translucent fill over a backdrop blur, both platforms and both bar styles.
Frosted bars draw over an extended body, so the shell's extendBody and the
tab clearance now key off NooBottomBar.drawsBehindBody.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-05 22:16:27 -04:00
co-authored by Claude Sonnet 5.5
parent d6448984d9
commit 944b87b054
9 changed files with 191 additions and 30 deletions
+10 -1
View File
@@ -233,7 +233,16 @@ blocks are noted where they matter:
that constant's doc comment) instead of edge to edge (no opacity/blur knob
either). `floating` ignores `NooNavStyle` and always uses the Android row
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
only the attached bar still has a distinct iOS row. The host `Scaffold` needs `extendBody: true` while it's active
only the attached bar still has a distinct iOS row. A separate, orthogonal
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
"Frosted glass bottom bar", off by default, both platforms and both bar
styles) swaps the bar's solid `surface` for a 0.72-alpha one over a
20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops
`nooDialogShadow` (it would show through the glass). A frosted bar only
reads as glass with content behind it, so the shell uses
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
which also means every tab's own scrollable list has to reserve enough
bottom padding to clear the bar - nothing does that automatically once the
+8 -3
View File
@@ -36,7 +36,7 @@ import 'widgets/create_menu.dart';
import 'widgets/noo/core/noo_avatar.dart';
import 'widgets/noo/core/noo_button.dart';
import 'widgets/noo/core/noo_fab.dart';
import 'widgets/noo/nav/noo_nav_style.dart';
import 'widgets/noo/nav/noo_bottom_bar.dart';
import 'widgets/noo/nav/noo_sidebar.dart';
import 'widgets/noo/nav/noo_toolbar.dart';
import 'widgets/noo/noo_layout.dart';
@@ -526,8 +526,12 @@ class _MainShellViewState extends State<MainShellView> {
settings.navMenuStyle == NooNavMenuStyle.drawer,
// Floating needs the body to draw behind the bar's own transparent
// margin (see NooBottomBarStyle's doc comment) instead of stopping
// short of it like attached does.
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
// short of it like attached does; a frosted bar needs it too, or
// there'd be nothing behind it to blur.
extendBody: NooBottomBar.drawsBehindBody(
bottomBarStyle,
settings.bottomBarFrosted,
),
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos,
@@ -549,6 +553,7 @@ class _MainShellViewState extends State<MainShellView> {
bottomNavigationBar: BottomNavBar(
style: navStyle,
barStyle: bottomBarStyle,
frosted: settings.bottomBarFrosted,
tabs: pinnedTabs,
selectedIndex: pinnedIndex,
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
+12
View File
@@ -38,6 +38,7 @@ class SettingsController extends ChangeNotifier {
static const _prefSwipeRightAction = 'ui_swipe_right_action';
static const _prefFabStyle = 'ui_fab_style';
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
static const _prefNavMenuStyle = 'ui_nav_menu_style';
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
static const _prefAmoledDark = 'ui_amoled_dark';
@@ -51,6 +52,7 @@ class SettingsController extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
bool _bottomBarFrosted = false;
bool _searchInBottomBar = false;
bool _useDynamicColor = true;
bool _amoledDark = false;
@@ -95,6 +97,7 @@ class SettingsController extends ChangeNotifier {
Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
bool get bottomBarFrosted => _bottomBarFrosted;
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
bool get searchInBottomBar => _searchInBottomBar;
bool get useDynamicColor => _useDynamicColor;
@@ -155,6 +158,8 @@ class SettingsController extends ChangeNotifier {
orElse: () => _navMenuStyle,
);
}
_bottomBarFrosted =
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
_searchInBottomBar =
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
_useDynamicColor =
@@ -313,6 +318,13 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
}
void setBottomBarFrosted(bool value) {
if (_bottomBarFrosted == value) return;
_bottomBarFrosted = value;
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value));
}
void setNavMenuStyle(NooNavMenuStyle style) {
if (_navMenuStyle == style) return;
_navMenuStyle = style;
+7 -4
View File
@@ -837,10 +837,13 @@ class _FilesViewState extends State<FilesView> {
),
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
// once Scaffold shrinks the body above an attached bar, but floating
// draws the body behind the bar instead, so it needs the bar's own
// footprint added on top or the last row ends up under it.
if (context.watch<SettingsController>().bottomBarStyle ==
NooBottomBarStyle.floating)
// draws the body behind the bar instead (as does a frosted bar), so it
// needs the bar's own footprint added on top or the last row ends up
// under it.
if (NooBottomBar.drawsBehindBody(
context.watch<SettingsController>().bottomBarStyle,
context.watch<SettingsController>().bottomBarFrosted,
))
SliverToBoxAdapter(
child: SizedBox(height: bottomBarClearance(context)),
),
+3
View File
@@ -16,6 +16,7 @@ import 'noo/nav/noo_bottom_bar.dart';
class BottomNavBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final bool frosted;
final List<AppTab> tabs;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
@@ -25,6 +26,7 @@ class BottomNavBar extends StatelessWidget {
super.key,
required this.style,
this.barStyle = NooBottomBarStyle.attached,
this.frosted = false,
required this.tabs,
required this.selectedIndex,
required this.onDestinationSelected,
@@ -36,6 +38,7 @@ class BottomNavBar extends StatelessWidget {
return NooBottomBar(
style: style,
barStyle: barStyle,
frosted: frosted,
destinations: [
for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label),
+66 -18
View File
@@ -1,3 +1,4 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
import 'noo_nav_style.dart';
@@ -13,6 +14,11 @@ const double _kFloatingPillTop = 8;
const double _kAndroidPillWidth = 56;
const double _kAndroidPillHeight = 32;
/// Frosted glass: the surface fill's opacity and the backdrop blur sigma -
/// the same sigma [FrostedGlassContainer] uses for the media viewer chrome.
const double _kFrostedAlpha = 0.72;
const double _kFrostedSigma = 20;
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
/// what order, is the caller's business; this only draws [destinations].
@@ -48,6 +54,13 @@ const double _kAndroidPillHeight = 32;
/// body draws behind it - see `tab_state_slivers.dart`'s
/// `bottomBarClearance`.
///
/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a
/// translucent one over a backdrop blur, on both bar styles and both nav
/// styles, and drops [nooDialogShadow] (a shadow would show through the
/// glass). It only reads as glass if content draws behind the bar, so the
/// host `Scaffold` needs `extendBody: true` and each scrollable body needs
/// the same trailing clearance floating does - see [drawsBehindBody].
///
/// [searchDestination]/[onSearchTap] (set together, from Settings'
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
/// entry) add a Search entry that's never highlighted (tapping it pushes
@@ -57,6 +70,7 @@ const double _kAndroidPillHeight = 32;
class NooBottomBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final bool frosted;
final List<NooNavDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
@@ -67,6 +81,7 @@ class NooBottomBar extends StatelessWidget {
super.key,
required this.style,
this.barStyle = NooBottomBarStyle.attached,
this.frosted = false,
required this.destinations,
required this.selectedIndex,
required this.onSelected,
@@ -84,6 +99,12 @@ class NooBottomBar extends StatelessWidget {
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
}
/// Whether the host `Scaffold` must draw its body behind the bar
/// (`extendBody`) - true for floating (its transparent margin) and for any
/// frosted bar (there's nothing to blur otherwise).
static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) =>
frosted || barStyle == NooBottomBarStyle.floating;
/// Gap between the floating bar's bottom edge and the safe area below it
/// (itself inside the [SafeArea] that consumes the actual device inset).
static const double floatingBottomMargin = 12;
@@ -111,17 +132,19 @@ class NooBottomBar extends StatelessWidget {
final barHeight = NooBottomBar.rowHeight(style, barStyle);
if (floating) {
final pill = Container(
final pillRadius = BorderRadius.circular(28);
final pillBox = Container(
height: barHeight,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colors.surface,
color: _fill(colors),
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(28),
boxShadow: const [nooDialogShadow],
borderRadius: pillRadius,
boxShadow: frosted ? null : const [nooDialogShadow],
),
child: row,
);
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
return SafeArea(
top: false,
child: Padding(
@@ -152,6 +175,7 @@ class NooBottomBar extends StatelessWidget {
destination: searchDestination!,
onTap: onSearchTap!,
size: barHeight,
frosted: frosted,
),
],
),
@@ -161,9 +185,9 @@ class NooBottomBar extends StatelessWidget {
);
}
return Container(
final bar = Container(
decoration: BoxDecoration(
color: colors.surface,
color: _fill(colors),
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
),
child: SafeArea(
@@ -171,8 +195,13 @@ class NooBottomBar extends StatelessWidget {
child: SizedBox(height: barHeight, child: row),
),
);
return frosted ? _blurred(bar, BorderRadius.zero) : bar;
}
Color _fill(NooColors colors) => frosted
? colors.surface.withValues(alpha: _kFrostedAlpha)
: colors.surface;
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -255,6 +284,16 @@ class NooBottomBar extends StatelessWidget {
}
}
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
Widget _blurred(Widget child, BorderRadius radius) => ClipRRect(
borderRadius: radius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma),
child: child,
),
);
/// The floating bar's own Search entry point, next to the pill rather than
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
/// same fill/border as the pill so the two still read as one family.
@@ -262,11 +301,13 @@ class _SearchSatellite extends StatelessWidget {
final NooNavDestination destination;
final VoidCallback onTap;
final double size;
final bool frosted;
const _SearchSatellite({
required this.destination,
required this.onTap,
required this.size,
required this.frosted,
});
@override
@@ -278,21 +319,28 @@ class _SearchSatellite extends StatelessWidget {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.surface,
border: Border.all(color: colors.line),
shape: BoxShape.circle,
boxShadow: const [nooDialogShadow],
),
child: Icon(destination.icon, size: 24, color: colors.fg1),
),
child: _satelliteBody(colors),
),
);
}
Widget _satelliteBody(NooColors colors) {
final disc = Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: frosted
? colors.surface.withValues(alpha: _kFrostedAlpha)
: colors.surface,
border: Border.all(color: colors.line),
shape: BoxShape.circle,
boxShadow: frosted ? null : const [nooDialogShadow],
),
child: Icon(destination.icon, size: 24, color: colors.fg1),
);
return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc;
}
}
class _IosItem extends StatelessWidget {
@@ -53,6 +53,17 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [
_ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings),
NooSettingsRow(
icon: LucideIcons.glassWater,
label: const Text('Frosted glass bottom bar'),
subtitle: const Text(
'A translucent, blurred bottom bar - content scrolls behind it',
),
trailing: NooToggle(
checked: settings.bottomBarFrosted,
onChanged: settings.setBottomBarFrosted,
),
),
_NavMenuStyleRow(settings: settings),
NooSettingsRow(
icon: LucideIcons.search,
+11 -4
View File
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
/// Scaffold already shrinks the body above them - this is then pure
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
/// bars draw over an extended body instead, so nothing reserves space for
/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an
/// extended body instead, so nothing reserves space for
/// them automatically: the clearance has to cover the bar's own footprint
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
/// the safe-area inset below it, or the last row ends up hidden under it.
double bottomBarClearance(BuildContext context) {
final settings = context.watch<SettingsController>();
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
final barStyle = settings.bottomBarStyle;
if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) {
return 100;
}
final barHeight = NooBottomBar.rowHeight(
NooLayout.navStyle(context),
NooBottomBarStyle.floating,
barStyle,
);
final margin = barStyle == NooBottomBarStyle.floating
? NooBottomBar.floatingBottomMargin
: 0;
final safeBottom = MediaQuery.paddingOf(context).bottom;
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
return barHeight + margin + safeBottom + 24;
}
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
+63
View File
@@ -25,6 +25,69 @@ void main() {
setUpNooTests();
group('NooBottomBar', () {
for (final style in NooNavStyle.values) {
for (final barStyle in NooBottomBarStyle.values) {
testNooWidgets(
'$style/$barStyle: frosted draws a backdrop blur, solid does not',
(tester, theme, c) async {
for (final frosted in [false, true]) {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 120,
child: NooBottomBar(
style: style,
barStyle: barStyle,
frosted: frosted,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
searchDestination: const NooNavDestination(
icon: LucideIcons.search,
label: 'Search',
),
onSearchTap: () {},
),
),
theme: theme,
);
expect(
find.byType(BackdropFilter),
frosted ? findsWidgets : findsNothing,
);
}
},
);
}
}
test('drawsBehindBody: floating or frosted', () {
expect(
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
isFalse,
);
expect(
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, true),
isTrue,
);
expect(
NooBottomBar.drawsBehindBody(NooBottomBarStyle.floating, false),
isTrue,
);
});
test('floating row height is the Android one on both nav styles', () {
expect(
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.floating),
64,
);
expect(
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.attached),
50,
);
});
testNooWidgets('android: label sits below the icon, not beside it', (
tester,
theme,