From fe30871e36b3ed705b3cdb07b97b41643439bb2d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:29:00 +0700 Subject: [PATCH] fix(mornye): preserve navbar blur during collapse animations --- lib/widgets/mornye_bottom_bar.dart | 97 +++++++--- lib/widgets/mornye_chrome.dart | 282 +++++++++++++++-------------- test/mornye_bottom_bar_test.dart | 103 +++++++++++ 3 files changed, 323 insertions(+), 159 deletions(-) diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index c3432d58..d71cf05c 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -61,7 +61,7 @@ class MornyeChromeController extends ValueNotifier { /// 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 createState() => _MornyeBottomBarState(); +} + +class _MornyeBottomBarState extends ConsumerState + 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( - 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, ), ), ), diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index b9c618f5..da4daad5 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -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 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 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, - ), - ), - ], + ], + ), ), ), ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index d55ef099..7c22c715 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -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 expectBlur(Offset center, String phase) async { + final variation = await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final values = []; + 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; +}