diff --git a/integration_test/mornye_glass_visual_test.dart b/integration_test/mornye_glass_visual_test.dart index 978dc8ff..ca01284e 100644 --- a/integration_test/mornye_glass_visual_test.dart +++ b/integration_test/mornye_glass_visual_test.dart @@ -92,7 +92,10 @@ void main() { return builder.animation as CurvedAnimation; } - Future capture(String name) async { + Future capture( + String name, { + Map details = const {}, + }) async { await tester.pump(); // Android can acquire the previously displayed image before the new // Flutter frame reaches its image view. Prime that view, then capture the @@ -122,6 +125,7 @@ void main() { // ignore: invalid_use_of_protected_member visit(tester.binding.renderViews.first.layer!); poses[name] = { + ...details, 'collapseProgress': fold().value, 'controllerProgress': fold().parent.value, 'shaderBackdrops': histogram['_ImpellerShaderBackdropLayer'] ?? 0, @@ -240,6 +244,41 @@ void main() { ); final home = tab('Home'); final search = tab('Search'); + Rect capsule() => tester.getRect( + find.descendant( + of: find.byType(MornyeTabBar), + matching: find.byType(MornyeGlass), + ), + ); + Map foreground() => { + for (final entry in const { + 'Home': Icons.home, + 'Library': Icons.music_note, + 'Repo': Icons.grid_view, + 'Search': Icons.search, + }.entries) ...{ + '${entry.key}_icon': tester.getRect( + find.descendant( + of: find.byType(MornyeTabBar), + matching: find.byIcon(entry.value), + ), + ), + '${entry.key}_label': tester.getRect( + find.descendant( + of: find.byType(MornyeTabBar), + matching: find.text(entry.key), + ), + ), + }, + }; + List bounds(Rect rect) => [ + rect.left, + rect.top, + rect.width, + rect.height, + ]; + final restingCapsule = capsule(); + final restingForeground = foreground(); final touch = await tester.startGesture(home); try { await wait(320); @@ -257,11 +296,37 @@ void main() { await wait(100); } expect(tester.widget(lens).progress, 1); - await capture('${prefix}_held_halfway'); + final heldCapsule = capsule(); + expect(heldCapsule.width, closeTo(restingCapsule.width * 1.025, 0.01)); + expect(heldCapsule.height, closeTo(restingCapsule.height * 1.06, 0.01)); + expect(foreground(), restingForeground); + final glass = tester.widget( + find.descendant( + of: find.byType(MornyeTabBar), + matching: find.byWidgetPredicate( + (widget) => widget is MornyeLiquidBackdrop && !widget.interaction, + ), + ), + ); + expect(glass.refractionDepth, closeTo(0.60, 0.001)); + await capture( + '${prefix}_held_halfway', + details: { + 'restingCapsule': bounds(restingCapsule), + 'heldCapsule': bounds(heldCapsule), + 'fixedForegroundBounds': { + for (final entry in restingForeground.entries) + entry.key: bounds(entry.value), + }, + 'heldRefractionDepth': glass.refractionDepth, + }, + ); } finally { await touch.cancel(); } await wait(300); + expect(capsule(), restingCapsule); + expect(foreground(), restingForeground); expect(tester.state(find.byType(MiniPlayer)), same(player)); expect(tester.takeException(), isNull); } diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 8e2cb30c..4dec6246 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -120,6 +120,7 @@ class MornyeGlass extends ConsumerWidget { this.tintOpacity, this.tintColor, this.highlightBorder = false, + this.refractionDepth = 0.30, }) : firstInGroup = true, lastInGroup = true, samplesBackdrop = true; @@ -136,6 +137,7 @@ class MornyeGlass extends ConsumerWidget { this.tintColor, this.samplesBackdrop = true, this.highlightBorder = false, + this.refractionDepth = 0.30, }); // Quantized and shared across surfaces. Avoid composed color filters and @@ -157,6 +159,7 @@ class MornyeGlass extends ConsumerWidget { /// Paint the edge above the fill so tinted popovers retain a visible rim. final bool highlightBorder; + final double refractionDepth; /// Plain-page surfaces keep their tint without filtering a uniform backdrop. final bool samplesBackdrop; @@ -282,6 +285,7 @@ class MornyeGlass extends ConsumerWidget { borderRadius: shape, clarity: clarity, blurSigma: _blurSigmas[filterIndex], + refractionDepth: refractionDepth, child: surface, ) : sampleBackdrop @@ -511,8 +515,8 @@ class MornyeTabBar extends StatelessWidget { blurEnabled: blurEnabled, liquidGlass: liquidGlass, ); - final content = FadeTransition( - opacity: contentOpacity, + return Padding( + padding: EdgeInsets.symmetric(vertical: floating ? 8 : 0), child: MornyeSelectionPill( selectionColor: scheme.primary, maskItemForeground: false, @@ -525,6 +529,28 @@ class MornyeTabBar extends StatelessWidget { horizontal: floating ? 6 : 5, vertical: 5, ), + interactionBuilder: (context, progress, child) => Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + // Only the capsule projects forward. Icons, labels and touch + // coordinates stay fixed, and collapse never fades the backdrop. + child: Transform.scale( + scaleX: 1 + 0.025 * progress, + scaleY: 1 + 0.06 * progress, + child: MornyeGlass.navigation( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.navigationOpacity(context), + refractionDepth: 0.45 + 0.15 * progress, + child: const SizedBox.expand(), + ), + ), + ), + // The selection lens can swell beyond the capsule's clip. + FadeTransition(opacity: contentOpacity, child: child), + ], + ), itemBuilder: (context, index, selected) => ConstrainedBox( constraints: const BoxConstraints(minHeight: 54), child: Padding( @@ -566,23 +592,5 @@ class MornyeTabBar extends StatelessWidget { ), ), ); - return Padding( - padding: EdgeInsets.symmetric(vertical: floating ? 8 : 0), - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: MornyeGlass.navigation( - blurEnabled: blurEnabled, - strongTint: true, - tintOpacity: MornyeTheme.navigationOpacity(context), - child: const SizedBox.expand(), - ), - ), - // Keep the held lens above the bar's clip so it can swell outward. - content, - ], - ), - ); } } diff --git a/lib/widgets/mornye_liquid_backdrop.dart b/lib/widgets/mornye_liquid_backdrop.dart index 4afcccb5..68be0b5c 100644 --- a/lib/widgets/mornye_liquid_backdrop.dart +++ b/lib/widgets/mornye_liquid_backdrop.dart @@ -11,6 +11,7 @@ class MornyeLiquidBackdrop extends StatelessWidget { required this.child, this.interaction = false, this.progress = 1, + this.refractionDepth = 0.30, }); final BorderRadius borderRadius; @@ -19,6 +20,7 @@ class MornyeLiquidBackdrop extends StatelessWidget { final Widget child; final bool interaction; final double progress; + final double refractionDepth; @override Widget build(BuildContext context) { @@ -45,7 +47,7 @@ class MornyeLiquidBackdrop extends StatelessWidget { refraction: LiquidGlassRefraction( refractionType: OpticalRefraction( refraction: 1.7, - depth: 0.30 * effect, + depth: refractionDepth * effect, refractionWidth: 20, ), chromaticAberration: 0.002 * clarity * effect, diff --git a/lib/widgets/mornye_selection_pill.dart b/lib/widgets/mornye_selection_pill.dart index 39995af0..559d5141 100644 --- a/lib/widgets/mornye_selection_pill.dart +++ b/lib/widgets/mornye_selection_pill.dart @@ -25,6 +25,7 @@ class MornyeSelectionPill extends StatefulWidget { this.liquidInteraction = false, this.selectionColor, this.maskItemForeground = true, + this.interactionBuilder, }); final List labels; @@ -41,6 +42,11 @@ class MornyeSelectionPill extends StatefulWidget { final Color? selectionColor; final bool maskItemForeground; + /// Lets the bar animate its glass behind the fixed-size foreground using the + /// same press/release animation as the selection lens. + final Widget Function(BuildContext context, double progress, Widget child)? + interactionBuilder; + @override State createState() => _MornyeSelectionPillState(); } @@ -141,7 +147,8 @@ class _MornyeSelectionPillState extends State ui.ImageFilter.isShaderFilterSupported && !MediaQuery.disableAnimationsOf(context) && !MediaQuery.highContrastOf(context); - return Listener( + final pressProgress = lensEnabled ? _pressAnimation.value : 0.0; + final content = Listener( // An accepted drag can end with a PointerCancelEvent. Do not navigate // when the OS interrupts the gesture (e.g. opening system controls). onPointerDown: (event) { @@ -258,11 +265,11 @@ class _MornyeSelectionPillState extends State ), ), if (lensEnabled && selected >= 0 && selected < count) - _interactionLens(selected, count, _pressAnimation.value), + _interactionLens(selected, count, pressProgress), _foreground( selected, count, - lensEnabled ? _pressAnimation.value : 0, + pressProgress, Row( key: _foregroundKey, children: [ @@ -289,27 +296,7 @@ class _MornyeSelectionPillState extends State : Colors.transparent, onTap: () => widget.onChanged(index), child: ExcludeSemantics( - child: AnimatedScale( - scale: - lensEnabled && _pressed && selected >= 0 - ? 1 + - 0.18 * - _coverage( - index, - selected, - count, - ) - : 1, - duration: - _dragAlignment != null || - MediaQuery.disableAnimationsOf( - context, - ) - ? Duration.zero - : const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - child: _item(context, index, selected), - ), + child: _item(context, index, selected), ), ), ), @@ -324,6 +311,8 @@ class _MornyeSelectionPillState extends State ), ), ); + return widget.interactionBuilder?.call(context, pressProgress, content) ?? + content; } Widget _item(BuildContext context, int index, int selected) { @@ -337,13 +326,6 @@ class _MornyeSelectionPillState extends State : child; } - double _coverage(int index, int selected, int count) { - final position = _dragAlignment == null - ? selected.toDouble() - : (_dragAlignment! + 1) * (count - 1) / 2; - return (1 - (index - position).abs()).clamp(0.0, 1.0); - } - Widget _foreground( int selected, int count,