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:
co-authored by
Claude Sonnet 5.5
parent
d6448984d9
commit
944b87b054
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 -
|
||||
|
||||
Reference in New Issue
Block a user