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
+5
View File
@@ -37,6 +37,11 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
? 0.42
: 0.70;
static double navigationOpacity(BuildContext context) =>
Theme.of(context).extension<MornyeTheme>()?.chromeSurface != null
? 0.28
: 0.54;
static const lightAccent = Color.fromRGBO(204, 46, 51, 1);
static const darkAccent = Color.fromRGBO(224, 61, 60, 1);
+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,
),
),
+61 -48
View File
@@ -152,20 +152,16 @@ void main() {
(3, 'Search'),
(0, 'Home'),
]) {
if (index == 3) {
await tester.tap(find.byTooltip('Search'));
} else {
await tester.tapAt(
tester.getCenter(
find
.descendant(
of: find.byType(MornyeTabBar),
matching: find.text(label),
)
.first,
),
);
}
await tester.tapAt(
tester.getCenter(
find
.descendant(
of: find.byType(MornyeTabBar),
matching: find.text(label),
)
.first,
),
);
await tester.pumpAndSettle();
expect(activeTab.value, index);
final tabIcons = find.descendant(
@@ -221,8 +217,8 @@ void main() {
expect(
tester
.widget<MornyeTabBar>(find.byType(MornyeTabBar))
.hiddenIconIndex,
index == 3 ? 0 : index,
.hiddenIconIndices,
{index == 3 ? 0 : index, 3},
);
await tester.pumpAndSettle();
expect(
@@ -238,7 +234,7 @@ void main() {
await tester.pumpAndSettle();
expect(
tester.widget<MornyeTabBar>(find.byType(MornyeTabBar)).selectedIndex,
index == 3 ? -1 : index,
index,
);
}
});
@@ -285,37 +281,54 @@ void main() {
},
);
testWidgets('Search is a separate circle at the right (glass: $blur)', (
tester,
) async {
await pumpShell(tester, blur: blur);
final tabs = find.byType(MornyeTabBar);
final search = find.byKey(const ValueKey('mornye-compact-search'));
final repo = find.descendant(of: tabs, matching: find.text('Repo')).first;
expect(tester.widget<MornyeTabBar>(tabs).destinations, hasLength(3));
expect(
find.descendant(of: tabs, matching: find.text('Search')),
findsNothing,
);
expect(
find.descendant(of: tabs, matching: find.text('Settings')),
findsNothing,
);
expect(
tester.getCenter(search).dx,
greaterThan(tester.getCenter(repo).dx),
);
expect(
tester.getRect(search).left - tester.getRect(tabs).right,
closeTo(12, 0.1),
);
expect(tester.getSize(search), const Size(64, 64));
// The glass renderer paints the labels under a gesture overlay.
await tester.tapAt(tester.getCenter(search));
await tester.pumpAndSettle();
expect(searches, 1);
expect(tester.takeException(), isNull);
});
testWidgets(
'Search joins full tabs and separates when compact (glass: $blur)',
(tester) async {
await pumpShell(tester, blur: blur);
final tabs = find.byType(MornyeTabBar);
final search = find.byKey(const ValueKey('mornye-compact-search'));
final repo = find
.descendant(of: tabs, matching: find.text('Repo'))
.first;
expect(tester.widget<MornyeTabBar>(tabs).destinations, hasLength(4));
final searchLabel = find
.descendant(of: tabs, matching: find.text('Search'))
.first;
expect(
find.descendant(of: tabs, matching: find.text('Search')),
findsWidgets,
);
expect(
find.descendant(of: tabs, matching: find.text('Settings')),
findsNothing,
);
expect(
tester.getCenter(search).dx,
greaterThan(tester.getCenter(repo).dx),
);
expect(
tester.getSize(tabs).width,
tester.getSize(find.byType(MornyeBottomBar)).width,
);
final searchIcon = find
.descendant(of: tabs, matching: find.byIcon(Icons.search))
.first;
expect(
(tester.getCenter(search) - tester.getCenter(searchIcon)).distance,
lessThan(0.5),
);
// The glass renderer paints the labels under a gesture overlay.
await tester.tapAt(tester.getCenter(searchLabel));
await tester.pumpAndSettle();
expect(searches, 1);
chrome.value = true;
await tester.pumpAndSettle();
expect(tester.getSize(search), const Size(52, 52));
await tester.tap(search);
expect(searches, 2);
expect(tester.takeException(), isNull);
},
);
}
for (final withPlayer in [false, true]) {