fix(mornye): preserve navbar blur during collapse animations

This commit is contained in:
zarzet committed 2026-10-01 01:29:00 +07:00
1 parent d1eba29a4a
commit fe30871e36
3 files changed
+323 -159

No files matched your search

+73 -24
View File
@@ -61,7 +61,7 @@ class MornyeChromeController extends ValueNotifier<bool> {
/// A single mini-player survives the transition, preserving its artwork Hero,
/// playback controls and swipe-to-dismiss state as the tabs fold away.
class MornyeBottomBar extends ConsumerWidget {
class MornyeBottomBar extends ConsumerStatefulWidget {
const MornyeBottomBar({
super.key,
required this.collapsed,
@@ -82,7 +82,59 @@ class MornyeBottomBar extends ConsumerWidget {
final bool blurEnabled;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<MornyeBottomBar> createState() => _MornyeBottomBarState();
}
class _MornyeBottomBarState extends ConsumerState<MornyeBottomBar>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 380),
value: widget.collapsed ? 1 : 0,
);
late final _collapse = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
);
late final _tabOpacity = ReverseAnimation(_collapse);
void _updateCollapse() {
final target = widget.collapsed ? 1.0 : 0.0;
if (MediaQuery.disableAnimationsOf(context)) {
_controller.value = target;
} else {
_controller.animateTo(target);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_updateCollapse();
}
@override
void didUpdateWidget(MornyeBottomBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.collapsed != widget.collapsed) _updateCollapse();
}
@override
void dispose() {
_collapse.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final collapsed = widget.collapsed;
final destinations = widget.destinations;
final selectedIndex = widget.selectedIndex;
final onSelected = widget.onSelected;
final onHome = widget.onHome;
final onSearch = widget.onSearch;
final blurEnabled = widget.blurEnabled;
final hasPlayer = ref.watch(
currentMediaItemProvider.select((item) => item.value != null),
);
@@ -95,14 +147,18 @@ 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(
child: MornyeGlass.navigation(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.navigationOpacity(context),
radius: 26,
child: const SizedBox.expand(),
// instances so folding only updates geometry and foreground opacity. A
// backdrop inside a fading layer loses access to the page behind the bar.
Widget sideSurface() => ScaleTransition(
scale: _collapse,
child: RepaintBoundary(
child: MornyeGlass.navigation(
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.navigationOpacity(context),
radius: 26,
child: const SizedBox.expand(),
),
),
);
final leadingSurface = sideSurface();
@@ -122,6 +178,7 @@ class MornyeBottomBar extends ConsumerWidget {
index == destinations.length - 1 ? onSearch() : onSelected(index),
blurEnabled: blurEnabled,
liquidGlass: liquidGlass,
contentOpacity: _tabOpacity,
hiddenIconIndices: hideMovingIcons
? {leadingIndex, destinations.length - 1}
: const {},
@@ -158,13 +215,10 @@ class MornyeBottomBar extends ConsumerWidget {
(constraints.maxWidth - tabInset * 2) /
destinations.length *
(index + 0.5);
return TweenAnimationBuilder<double>(
tween: Tween(end: collapsed ? 1 : 0),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 380),
curve: Curves.easeInOutCubic,
builder: (context, amount, _) {
return AnimatedBuilder(
animation: _collapse,
builder: (context, _) {
final amount = _collapse.value;
Widget movingIcon({
required bool leading,
required Widget surface,
@@ -188,9 +242,7 @@ class MornyeBottomBar extends ConsumerWidget {
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Opacity(opacity: amount, child: surface),
),
Positioned.fill(child: surface),
Material(
color: Colors.transparent,
child: IconButton(
@@ -269,10 +321,7 @@ class MornyeBottomBar extends ConsumerWidget {
ignoring: amount > 0.5,
child: ExcludeSemantics(
excluding: amount > 0.5,
child: Opacity(
opacity: 1 - amount,
child: amount == 0 ? fullTabs : foldingTabs,
),
child: amount == 0 ? fullTabs : foldingTabs,
),
),
),
+147 -135
View File
@@ -612,6 +612,7 @@ class MornyeTabBar extends StatelessWidget {
required this.blurEnabled,
this.liquidGlass = true,
this.hiddenIconIndices = const {},
this.contentOpacity = const AlwaysStoppedAnimation(1),
});
/// Shader capsule and travelling pill; otherwise the frosted row below.
@@ -638,6 +639,10 @@ class MornyeTabBar extends StatelessWidget {
// The active and Search icons move independently while the capsule folds.
final Set<int> hiddenIconIndices;
/// Fade icons and labels while the capsule folds. Its backdrop must stay
/// outside the fade layer so it can still sample the page during motion.
final Animation<double> contentOpacity;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -673,79 +678,83 @@ class MornyeTabBar extends StatelessWidget {
child: const SizedBox.expand(),
),
),
NativeGlassMetrics(
child: LiquidGlassTabBar.withImpeller(
width: constraints.maxWidth,
height: 64,
margin: const EdgeInsets.only(bottom: 8),
selectedIndex: selectedIndex < 0 ? 0 : selectedIndex,
onChanged: onSelected,
style: LiquidGlassTabBar.defaultStyle.copyWith(
// The frosted base owns the subtle outline. Disable the
// package's default specular rim around the whole capsule.
shape: const LiquidGlassShape.continuousRoundedRectangle(
cornerRadius: 32,
borderWidth: 0,
lightIntensity: 0,
),
// The sibling surface already supplies tint and blur. Keep
// the lens clear so the moving pill can refract the icons.
appearance: const LiquidGlassAppearance(),
refraction: const LiquidGlassRefraction(
distortion: 0,
chromaticAberration: 0,
),
),
itemStyle: LiquidGlassTabItemStyle(
selectedColor: scheme.primary,
unselectedColor: scheme.onSurface,
iconSize: 25,
labelFontSize: 11,
),
pillStyle: LiquidGlassTabPillStyle(
mode: LiquidGlassPillMode.impellerOnly,
show: selectedIndex >= 0,
color: selectionFill,
animated: true,
// Keep the moving refractive pill, without stacking the
// package's second magnifier lens beneath it.
magnifierPill: const LiquidGlassTabMagnifierPillStyle(
enabled: false,
),
),
items: [
for (final (index, destination) in destinations.indexed)
LiquidGlassTabBarItem(
label: destination.label,
iconBuilder: (context, icon) => IconTheme(
data: IconThemeData(
size: icon.size,
color: icon.selected && selectedIndex >= 0
? scheme.primary
: inactiveIconColor,
FadeTransition(
opacity: contentOpacity,
child: NativeGlassMetrics(
child: LiquidGlassTabBar.withImpeller(
width: constraints.maxWidth,
height: 64,
margin: const EdgeInsets.only(bottom: 8),
selectedIndex: selectedIndex < 0 ? 0 : selectedIndex,
onChanged: onSelected,
style: LiquidGlassTabBar.defaultStyle.copyWith(
// The frosted base owns the subtle outline. Disable the
// package's default specular rim around the whole capsule.
shape:
const LiquidGlassShape.continuousRoundedRectangle(
cornerRadius: 32,
borderWidth: 0,
lightIntensity: 0,
),
child: Opacity(
opacity: hiddenIconIndices.contains(index)
? 0
: 1,
child: destination.icon,
),
),
labelBuilder: (context, label) => Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
fontSize: label.textStyle.fontSize,
fontWeight: FontWeight.w600,
color: selectedIndex < 0
? scheme.onSurface
: label.textStyle.color,
),
),
// The sibling surface already supplies tint and blur. Keep
// the lens clear so the moving pill can refract the icons.
appearance: const LiquidGlassAppearance(),
refraction: const LiquidGlassRefraction(
distortion: 0,
chromaticAberration: 0,
),
],
),
itemStyle: LiquidGlassTabItemStyle(
selectedColor: scheme.primary,
unselectedColor: scheme.onSurface,
iconSize: 25,
labelFontSize: 11,
),
pillStyle: LiquidGlassTabPillStyle(
mode: LiquidGlassPillMode.impellerOnly,
show: selectedIndex >= 0,
color: selectionFill,
animated: true,
// Keep the moving refractive pill, without stacking the
// package's second magnifier lens beneath it.
magnifierPill: const LiquidGlassTabMagnifierPillStyle(
enabled: false,
),
),
items: [
for (final (index, destination) in destinations.indexed)
LiquidGlassTabBarItem(
label: destination.label,
iconBuilder: (context, icon) => IconTheme(
data: IconThemeData(
size: icon.size,
color: icon.selected && selectedIndex >= 0
? scheme.primary
: inactiveIconColor,
),
child: Opacity(
opacity: hiddenIconIndices.contains(index)
? 0
: 1,
child: destination.icon,
),
),
labelBuilder: (context, label) => Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
fontSize: label.textStyle.fontSize,
fontWeight: FontWeight.w600,
color: selectedIndex < 0
? scheme.onSurface
: label.textStyle.color,
),
),
),
],
),
),
),
// With no selected tab the package still needs an internal
@@ -784,77 +793,80 @@ class MornyeTabBar extends StatelessWidget {
blurEnabled: blurEnabled,
strongTint: true,
tintOpacity: MornyeTheme.navigationOpacity(context),
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(
children: [
for (var index = 0; index < destinations.length; index++)
Expanded(
child: Semantics(
selected: index == selectedIndex,
button: true,
label: destinations[index].label,
excludeSemantics: true,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: () => onSelected(index),
child: AnimatedContainer(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 220),
constraints: const BoxConstraints(minHeight: 54),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
decoration: BoxDecoration(
color: index == selectedIndex
? selectionFill
: Colors.transparent,
borderRadius: BorderRadius.circular(28),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconTheme(
data: IconThemeData(
size: 25,
color: index == selectedIndex
? scheme.primary
: inactiveIconColor,
child: FadeTransition(
opacity: contentOpacity,
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(
children: [
for (var index = 0; index < destinations.length; index++)
Expanded(
child: Semantics(
selected: index == selectedIndex,
button: true,
label: destinations[index].label,
excludeSemantics: true,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: () => onSelected(index),
child: AnimatedContainer(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 220),
constraints: const BoxConstraints(minHeight: 54),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
decoration: BoxDecoration(
color: index == selectedIndex
? selectionFill
: Colors.transparent,
borderRadius: BorderRadius.circular(28),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconTheme(
data: IconThemeData(
size: 25,
color: index == selectedIndex
? scheme.primary
: inactiveIconColor,
),
// Keep tab selection quiet, as in Mornye. Badges
// stay live without the Material bounce/spin.
child: Opacity(
opacity: hiddenIconIndices.contains(index)
? 0
: 1,
child: destinations[index].icon,
),
),
// Keep tab selection quiet, as in Mornye. Badges
// stay live without the Material bounce/spin.
child: Opacity(
opacity: hiddenIconIndices.contains(index)
? 0
: 1,
child: destinations[index].icon,
const SizedBox(height: 2),
Text(
destinations[index].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: index == selectedIndex
? scheme.primary
: scheme.onSurface,
),
),
),
const SizedBox(height: 2),
Text(
destinations[index].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: index == selectedIndex
? scheme.primary
: scheme.onSurface,
),
),
],
],
),
),
),
),
),
),
),
],
],
),
),
),
);
+103
View File
@@ -39,6 +39,7 @@ void main() {
WidgetTester tester, {
bool withPlayer = true,
bool blur = false,
bool liquidGlass = true,
Brightness brightness = Brightness.light,
Color? backdrop,
Color? chromeSurface,
@@ -67,6 +68,7 @@ void main() {
playbackStateProvider.overrideWith((ref) => const Stream.empty()),
lowEndDeviceProvider.overrideWithValue(!blur),
backdropBlurEnabledProvider.overrideWithValue(blur),
mornyeLiquidGlassProvider.overrideWithValue(liquidGlass),
],
child: MaterialApp(
theme: MornyeTheme.build(
@@ -135,6 +137,91 @@ void main() {
const albumBlue = Color(0xff464566);
for (final liquidGlass in [false, true]) {
testWidgets(
'navbar keeps its backdrop blurred throughout motion (liquid: $liquidGlass)',
(tester) async {
await pumpShell(
tester,
blur: true,
liquidGlass: liquidGlass,
brightness: Brightness.dark,
glassClarity: 1,
body: const SizedBox.expand(
child: CustomPaint(painter: _StripedBackdrop()),
),
);
Future<void> expectBlur(Offset center, String phase) async {
final variation = await tester.runAsync(() async {
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(capture),
);
final image = await boundary.toImage();
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final values = <int>[];
for (var x = -9; x <= 9; x++) {
final offset =
(center.dy.floor() * image.width + center.dx.floor() + x) * 4;
values.add(bytes.getUint8(offset));
}
image.dispose();
values.sort();
return values.last - values.first;
});
expect(variation, lessThan(20), reason: phase);
}
const capsuleSample = Offset(196, 745);
await expectBlur(capsuleSample, 'Expanded capsule');
final gesture = await tester.startGesture(
tester.getCenter(find.text('Home').first),
);
await gesture.moveBy(const Offset(100, 0));
await tester.pump(const Duration(milliseconds: 100));
await expectBlur(capsuleSample, 'Dragging the selected tab');
await gesture.up();
await tester.pumpAndSettle();
for (final collapsed in [true, false]) {
chrome.value = collapsed;
await tester.pump();
if (collapsed) {
await tester.pump(const Duration(milliseconds: 190));
await expectBlur(
capsuleSample,
'Capsule halfway to collapsed=$collapsed',
);
}
// Sample below the icons once the growing circles are large enough to
// expose their backdrop; the page stripes must stay blurred while moving.
await tester.pump(const Duration(milliseconds: 95));
for (final key in [
'mornye-compact-leading',
'mornye-compact-search',
]) {
final center = tester.getCenter(find.byKey(ValueKey(key)));
await expectBlur(
center + const Offset(0, 18),
'$key moving to collapsed=$collapsed',
);
}
if (!collapsed) {
await tester.pump(const Duration(milliseconds: 95));
await expectBlur(
capsuleSample,
'Capsule halfway to collapsed=$collapsed',
);
}
await tester.pumpAndSettle();
}
expect(tester.takeException(), isNull);
},
);
}
for (final blur in [false, true]) {
testWidgets('active edge icons and tabs stay synchronized (glass: $blur)', (
tester,
@@ -718,3 +805,19 @@ void main() {
expect(tester.takeException(), isNull);
});
}
class _StripedBackdrop extends CustomPainter {
const _StripedBackdrop();
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
for (var x = 0.0; x < size.width; x += 12) {
paint.color = (x / 12).round().isEven ? Colors.white : Colors.black;
canvas.drawRect(Rect.fromLTWH(x, 0, 12, size.height), paint);
}
}
@override
bool shouldRepaint(_StripedBackdrop oldDelegate) => false;
}