feat(navigation): refine translucent bottom bar and search folding

Keep Search inside the expanded tab bar and animate it into a separate control when collapsed. Reduce navigation surface tint while retaining clear icons and accessibility fallbacks.
This commit is contained in:
zarzet
2026-09-25 01:22:43 +07:00
parent 7a8dadf5f9
commit 64445a09fc
4 changed files with 109 additions and 105 deletions
+31 -43
View File
@@ -89,44 +89,42 @@ class MornyeBottomBar extends ConsumerWidget {
final tabGap = glassTabs ? 0.0 : 8.0;
// These contents do not depend on animation progress. Retain their widget
// instances so folding only updates size/opacity wrappers each frame.
Widget sideSurface({double radius = 26}) => RepaintBoundary(
Widget sideSurface() => RepaintBoundary(
child: MornyeGlass.navigation(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.chromeOpacity(context),
radius: radius,
tintOpacity: MornyeTheme.navigationOpacity(context),
radius: 26,
child: const SizedBox.expand(),
),
);
final leadingSurface = sideSurface();
final searchSurface = sideSurface(radius: 32);
final mainDestinations = destinations.sublist(0, destinations.length - 1);
final searchSurface = sideSurface();
final searchSelected = selectedIndex == destinations.length - 1;
final leadingIndex = searchSelected ? 0 : selectedIndex;
final scheme = Theme.of(context).colorScheme;
final inactiveIconColor = Color.lerp(
scheme.onSurfaceVariant,
scheme.onSurface,
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
);
final inactiveIconColor = scheme.onSurface;
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
Widget tabs({required bool hideLeadingIcon}) => TickerMode(
Widget tabs({required bool hideMovingIcons}) => TickerMode(
enabled: !collapsed,
child: RepaintBoundary(
child: MornyeTabBar(
destinations: mainDestinations,
selectedIndex: searchSelected ? -1 : selectedIndex,
onSelected: onSelected,
destinations: destinations,
selectedIndex: selectedIndex,
onSelected: (index) =>
index == destinations.length - 1 ? onSearch() : onSelected(index),
blurEnabled: blurEnabled,
hiddenIconIndex: hideLeadingIcon ? leadingIndex : null,
hiddenIconIndices: hideMovingIcons
? {leadingIndex, destinations.length - 1}
: const {},
),
),
);
// At rest the glass bar must paint its own active icon: its selected layer
// follows a dragged pill before the destination is committed. Hand them
// to the moving overlays only while folding, at the same coordinates.
final fullTabs = tabs(hideLeadingIcon: false);
final foldingTabs = tabs(hideLeadingIcon: true);
final fullTabs = tabs(hideMovingIcons: false);
final foldingTabs = tabs(hideMovingIcons: true);
return LayoutBuilder(
builder: (context, constraints) {
// Match the tab's actual label height, including accessibility scaling.
@@ -147,14 +145,11 @@ class MornyeBottomBar extends ConsumerWidget {
? 40 + (labelHeight + 2) / 2
: math.max(64.0, 49 + labelHeight) - 23.5;
final tabInset = glassTabs ? 6.0 : 5.0;
final fullIconStart =
double fullIconStart(int index) =>
tabInset +
(constraints.maxWidth - 76 - tabInset * 2) /
mainDestinations.length *
(leadingIndex + 0.5);
final fullSearchBottom = glassTabs
? 8.0
: (math.max(64.0, 49 + labelHeight) - 64) / 2;
(constraints.maxWidth - tabInset * 2) /
destinations.length *
(index + 0.5);
return TweenAnimationBuilder<double>(
tween: Tween(end: collapsed ? 1 : 0),
duration: MediaQuery.disableAnimationsOf(context)
@@ -167,28 +162,26 @@ class MornyeBottomBar extends ConsumerWidget {
required Widget surface,
}) {
final index = leading ? leadingIndex : destinations.length - 1;
final offset = (fullIconStart - 26) * (1 - amount);
final size = leading ? 52.0 : 64 - 12 * amount;
final origin = fullIconStart(index);
final offset =
((leading ? origin : constraints.maxWidth - origin) - 26) *
(1 - amount);
const size = 52.0;
return PositionedDirectional(
start: leading ? offset : null,
end: leading ? null : 0,
bottom: leading
? fullIconBottom * (1 - amount) + 34 * amount - 26
: fullSearchBottom * (1 - amount) + 8 * amount,
end: leading ? null : offset,
bottom: fullIconBottom * (1 - amount) + 34 * amount - 26,
width: size,
height: size,
child: IgnorePointer(
ignoring: leading && amount < 0.5,
ignoring: amount < 0.5,
child: ExcludeSemantics(
excluding: leading && amount < 0.5,
excluding: amount < 0.5,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Opacity(
opacity: leading ? amount : 1,
child: surface,
),
child: Opacity(opacity: amount, child: surface),
),
Material(
color: Colors.transparent,
@@ -214,7 +207,7 @@ class MornyeBottomBar extends ConsumerWidget {
amount,
),
icon: Opacity(
opacity: leading && amount == 0 ? 0 : 1,
opacity: amount == 0 ? 0 : 1,
child: destinations[index].icon,
),
onPressed: leading
@@ -274,12 +267,7 @@ class MornyeBottomBar extends ConsumerWidget {
excluding: amount > 0.5,
child: Opacity(
opacity: 1 - amount,
child: Padding(
padding: const EdgeInsetsDirectional.only(
end: 76,
),
child: amount == 0 ? fullTabs : foldingTabs,
),
child: amount == 0 ? fullTabs : foldingTabs,
),
),
),
+12 -14
View File
@@ -437,15 +437,15 @@ class MornyeTabBar extends StatelessWidget {
required this.selectedIndex,
required this.onSelected,
required this.blurEnabled,
this.hiddenIconIndex,
this.hiddenIconIndices = const {},
});
final List<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
final bool blurEnabled;
// The active icon moves independently while the full capsule folds away.
final int? hiddenIconIndex;
// The active and Search icons move independently while the capsule folds.
final Set<int> hiddenIconIndices;
@override
Widget build(BuildContext context) {
@@ -453,11 +453,7 @@ class MornyeTabBar extends StatelessWidget {
final selectionFill = scheme.onSurface.withValues(
alpha: scheme.brightness == Brightness.dark ? 0.12 : 0.08,
);
final inactiveIconColor = Color.lerp(
scheme.onSurfaceVariant,
scheme.onSurface,
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
);
final inactiveIconColor = scheme.onSurface;
if (blurEnabled &&
!MediaQuery.disableAnimationsOf(context) &&
!MediaQuery.highContrastOf(context)) {
@@ -480,7 +476,7 @@ class MornyeTabBar extends StatelessWidget {
child: _MornyeGlassSurface(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.chromeOpacity(context),
tintOpacity: MornyeTheme.navigationOpacity(context),
child: const SizedBox.expand(),
),
),
@@ -535,7 +531,7 @@ class MornyeTabBar extends StatelessWidget {
: inactiveIconColor,
),
child: Opacity(
opacity: hiddenIconIndex == index ? 0 : 1,
opacity: hiddenIconIndices.contains(index) ? 0 : 1,
child: destination.icon,
),
),
@@ -555,8 +551,8 @@ class MornyeTabBar extends StatelessWidget {
),
],
),
// Search lives outside the capsule. With no selected tab the
// package still needs an internal index; handle taps here so
// With no selected tab the package still needs an internal
// index; handle taps here so
// returning to that index (Home) is not swallowed as a re-tap.
if (selectedIndex < 0)
Positioned(
@@ -590,7 +586,7 @@ class MornyeTabBar extends StatelessWidget {
return MornyeGlass.navigation(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.chromeOpacity(context),
tintOpacity: MornyeTheme.navigationOpacity(context),
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(
@@ -635,7 +631,9 @@ class MornyeTabBar extends StatelessWidget {
// Keep tab selection quiet, as in Mornye. Badges
// stay live without the Material bounce/spin.
child: Opacity(
opacity: hiddenIconIndex == index ? 0 : 1,
opacity: hiddenIconIndices.contains(index)
? 0
: 1,
child: destinations[index].icon,
),
),