feat(mornye): separate Search into a circular navigation button

Keep Home, Library, and Repo in the glass capsule while Search remains accessible on the right. Preserve compact transitions and allow returning to Home when Search is active.
This commit is contained in:
zarzet
2026-09-21 18:15:52 +07:00
parent 1c535b493c
commit ed80d3e8bb
3 changed files with 115 additions and 62 deletions
+45 -21
View File
@@ -89,17 +89,19 @@ 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() => RepaintBoundary(
Widget sideSurface({double radius = 26}) => RepaintBoundary(
child: MornyeGlass.navigation(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.chromeOpacity(context),
radius: 26,
child: const SizedBox.square(dimension: 52),
radius: radius,
child: const SizedBox.expand(),
),
);
final homeSurface = sideSurface();
final searchSurface = sideSurface();
final searchSurface = sideSurface(radius: 32);
final mainDestinations = destinations.sublist(0, destinations.length - 1);
final searchSelected = selectedIndex == destinations.length - 1;
final scheme = Theme.of(context).colorScheme;
final inactiveIconColor = Color.lerp(
scheme.onSurfaceVariant,
@@ -107,23 +109,23 @@ class MornyeBottomBar extends ConsumerWidget {
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
);
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
Widget tabs({required bool hideEdgeIcons}) => TickerMode(
Widget tabs({required bool hideHomeIcon}) => TickerMode(
enabled: !collapsed,
child: RepaintBoundary(
child: MornyeTabBar(
destinations: destinations,
selectedIndex: selectedIndex,
destinations: mainDestinations,
selectedIndex: searchSelected ? -1 : selectedIndex,
onSelected: onSelected,
blurEnabled: blurEnabled,
hideEdgeIcons: hideEdgeIcons,
hideHomeIcon: hideHomeIcon,
),
),
);
// At rest the glass bar must paint its own edge icons: its selected layer
// At rest the glass bar must paint its own Home 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(hideEdgeIcons: false);
final foldingTabs = tabs(hideEdgeIcons: true);
final fullTabs = tabs(hideHomeIcon: false);
final foldingTabs = tabs(hideHomeIcon: true);
return LayoutBuilder(
builder: (context, constraints) {
// Match the tab's actual label height, including accessibility scaling.
@@ -146,7 +148,12 @@ class MornyeBottomBar extends ConsumerWidget {
final tabInset = glassTabs ? 6.0 : 5.0;
final fullIconStart =
tabInset +
(constraints.maxWidth - tabInset * 2) / destinations.length / 2;
(constraints.maxWidth - 76 - tabInset * 2) /
mainDestinations.length /
2;
final fullSearchBottom = glassTabs
? 8.0
: (math.max(64.0, 49 + labelHeight) - 64) / 2;
return TweenAnimationBuilder<double>(
tween: Tween(end: collapsed ? 1 : 0),
duration: MediaQuery.disableAnimationsOf(context)
@@ -157,20 +164,28 @@ class MornyeBottomBar extends ConsumerWidget {
Widget movingIcon({required bool home, required Widget surface}) {
final index = home ? 0 : destinations.length - 1;
final offset = (fullIconStart - 26) * (1 - amount);
final size = home ? 52.0 : 64 - 12 * amount;
return PositionedDirectional(
start: home ? offset : null,
end: home ? null : offset,
bottom: fullIconBottom * (1 - amount) + 34 * amount - 26,
width: 52,
height: 52,
end: home ? null : 0,
bottom: home
? fullIconBottom * (1 - amount) + 34 * amount - 26
: fullSearchBottom * (1 - amount) + 8 * amount,
width: size,
height: size,
child: IgnorePointer(
ignoring: amount < 0.5,
ignoring: home && amount < 0.5,
child: ExcludeSemantics(
excluding: amount < 0.5,
excluding: home && amount < 0.5,
child: Stack(
alignment: Alignment.center,
children: [
Opacity(opacity: amount, child: surface),
Positioned.fill(
child: Opacity(
opacity: home ? amount : 1,
child: surface,
),
),
Material(
color: Colors.transparent,
child: IconButton(
@@ -183,6 +198,10 @@ class MornyeBottomBar extends ConsumerWidget {
? context.l10n.navHome
: context.l10n.mornyeSearch,
iconSize: 25,
constraints: BoxConstraints.tightFor(
width: size,
height: size,
),
color: Color.lerp(
index == selectedIndex
? scheme.primary
@@ -191,7 +210,7 @@ class MornyeBottomBar extends ConsumerWidget {
amount,
),
icon: Opacity(
opacity: amount == 0 ? 0 : 1,
opacity: home && amount == 0 ? 0 : 1,
child: destinations[index].icon,
),
onPressed: home ? onHome : onSearch,
@@ -247,7 +266,12 @@ class MornyeBottomBar extends ConsumerWidget {
excluding: amount > 0.5,
child: Opacity(
opacity: 1 - amount,
child: amount == 0 ? fullTabs : foldingTabs,
child: Padding(
padding: const EdgeInsetsDirectional.only(
end: 76,
),
child: amount == 0 ? fullTabs : foldingTabs,
),
),
),
),
+37 -19
View File
@@ -437,16 +437,15 @@ class MornyeTabBar extends StatelessWidget {
required this.selectedIndex,
required this.onSelected,
required this.blurEnabled,
this.hideEdgeIcons = false,
this.hideHomeIcon = false,
});
final List<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
final bool blurEnabled;
// The folding bottom bar paints these two icons above both layouts so they
// can travel without fading along with the full tab capsule.
final bool hideEdgeIcons;
// Home moves independently while the full tab capsule folds away.
final bool hideHomeIcon;
@override
Widget build(BuildContext context) {
@@ -489,7 +488,7 @@ class MornyeTabBar extends StatelessWidget {
width: constraints.maxWidth,
height: 64,
margin: const EdgeInsets.only(bottom: 8),
selectedIndex: selectedIndex,
selectedIndex: selectedIndex < 0 ? 0 : selectedIndex,
onChanged: onSelected,
style: LiquidGlassTabBar.defaultStyle.copyWith(
// The frosted base owns the subtle outline. Disable the
@@ -515,6 +514,7 @@ class MornyeTabBar extends StatelessWidget {
),
pillStyle: LiquidGlassTabPillStyle(
mode: LiquidGlassPillMode.impellerOnly,
show: selectedIndex >= 0,
color: selectionFill,
animated: true,
// Keep the moving refractive pill, without stacking the
@@ -530,17 +530,12 @@ class MornyeTabBar extends StatelessWidget {
iconBuilder: (context, icon) => IconTheme(
data: IconThemeData(
size: icon.size,
color: icon.selected
color: icon.selected && selectedIndex >= 0
? scheme.primary
: inactiveIconColor,
),
child: Opacity(
opacity:
hideEdgeIcons &&
(index == 0 ||
index == destinations.length - 1)
? 0
: 1,
opacity: hideHomeIcon && index == 0 ? 0 : 1,
child: destination.icon,
),
),
@@ -552,12 +547,40 @@ class MornyeTabBar extends StatelessWidget {
?.copyWith(
fontSize: label.textStyle.fontSize,
fontWeight: FontWeight.w600,
color: label.textStyle.color,
color: selectedIndex < 0
? scheme.onSurface
: label.textStyle.color,
),
),
),
],
),
// Search lives outside the capsule. 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(
left: 6,
right: 6,
bottom: 8,
height: 64,
child: Row(
children: [
for (
var index = 0;
index < destinations.length;
index++
)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSelected(index),
child: const SizedBox.expand(),
),
),
],
),
),
],
),
),
@@ -612,12 +635,7 @@ class MornyeTabBar extends StatelessWidget {
// Keep tab selection quiet, as in Mornye. Badges
// stay live without the Material bounce/spin.
child: Opacity(
opacity:
hideEdgeIcons &&
(index == 0 ||
index == destinations.length - 1)
? 0
: 1,
opacity: hideHomeIcon && index == 0 ? 0 : 1,
child: destinations[index].icon,
),
),
+33 -22
View File
@@ -23,14 +23,12 @@ void main() {
late MornyeChromeController chrome;
late ScrollController scroll;
late int searches;
late int selected;
final capture = GlobalKey();
setUp(() {
chrome = MornyeChromeController();
scroll = ScrollController();
searches = 0;
selected = 0;
});
tearDown(() {
chrome.dispose();
@@ -109,11 +107,13 @@ void main() {
],
selectedIndex: activeTab?.value ?? 0,
onSelected: (index) {
selected = index;
activeTab?.value = index;
},
onHome: chrome.expand,
onSearch: () => searches++,
onSearch: () {
searches++;
activeTab?.value = 3;
},
blurEnabled: blur,
),
),
@@ -147,21 +147,25 @@ void main() {
(3, 'Search'),
(0, 'Home'),
]) {
await tester.tapAt(
tester.getCenter(
find
.descendant(
of: find.byType(MornyeTabBar),
matching: find.text(label),
)
.first,
),
);
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.pumpAndSettle();
expect(activeTab.value, index);
final tabIcons = find.descendant(
of: find.byType(MornyeTabBar),
matching: find.byIcon(index == 3 ? Icons.search : Icons.home),
matching: find.byIcon(Icons.home),
);
// Edge icons stay in the glass bar's selected/unselected layers at
// rest, so the pill can reveal their red state during a held drag.
@@ -189,7 +193,7 @@ void main() {
await tester.pumpAndSettle();
expect(
tester.widget<MornyeTabBar>(find.byType(MornyeTabBar)).selectedIndex,
index,
index == 3 ? -1 : index,
);
}
});
@@ -236,16 +240,18 @@ void main() {
},
);
testWidgets('four tabs keep Search at the right edge (glass: $blur)', (
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
.descendant(of: tabs, matching: find.text('Search'))
.first;
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));
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,
@@ -254,10 +260,15 @@ void main() {
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(selected, 3);
expect(searches, 1);
expect(tester.takeException(), isNull);
});
}