diff --git a/assets/shaders/mornye_selection_mask.frag b/assets/shaders/mornye_selection_mask.frag new file mode 100644 index 00000000..cc5d4873 --- /dev/null +++ b/assets/shaders/mornye_selection_mask.frag @@ -0,0 +1,19 @@ +#include + +uniform vec4 uRect; +uniform vec2 uScale; +uniform float uRadius; +uniform vec4 uColor; + +out vec4 fragColor; + +void main() { + vec2 center = uRect.xy + uRect.zw * 0.5; + vec2 halfSize = uRect.zw / uScale * 0.5; + vec2 position = (FlutterFragCoord().xy - center) / uScale; + vec2 corner = abs(position) - halfSize + vec2(uRadius); + float distance = length(max(corner, vec2(0.0))) + + min(max(corner.x, corner.y), 0.0) - uRadius; + float coverage = 1.0 - smoothstep(-0.5, 0.5, distance); + fragColor = vec4(uColor.rgb * uColor.a, uColor.a) * coverage; +} diff --git a/integration_test/mornye_liquid_glass_test.dart b/integration_test/mornye_liquid_glass_test.dart index a7af63bf..6c888f8e 100644 --- a/integration_test/mornye_liquid_glass_test.dart +++ b/integration_test/mornye_liquid_glass_test.dart @@ -17,6 +17,98 @@ import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('held foreground reaches the curved glass edge', (tester) async { + final capture = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Center( + child: RepaintBoundary( + key: capture, + child: SizedBox( + width: 360, + child: MornyeSelectionPill( + labels: const ['Home', 'Library', 'Repo', 'Search'], + selectedIndex: 0, + onChanged: (_) {}, + padding: EdgeInsets.zero, + liquidInteraction: true, + selectionColor: Colors.red, + itemBuilder: (_, _, _) => const SizedBox( + height: 64, + child: ColoredBox(color: Colors.white), + ), + ), + ), + ), + ), + ), + ); + await tester.runAsync(() async { + await LiquidGlassShaders.ensureLoaded(); + await ui.FragmentProgram.fromAsset( + 'assets/shaders/mornye_selection_mask.frag', + ); + }); + await tester.pumpAndSettle(); + final row = tester.getRect(find.byType(MornyeSelectionPill)); + final gesture = await tester.startGesture( + row.topLeft + const Offset(45, 32), + ); + await gesture.moveTo(row.topLeft + const Offset(90, 32)); + await tester.pump(); + final lens = find.byWidgetPredicate( + (widget) => widget is MornyeLiquidBackdrop && widget.interaction, + ); + for (final duration in [ + const Duration(milliseconds: 60), + const Duration(milliseconds: 240), + ]) { + await tester.pump(duration); + final bounds = tester.getRect(lens).shift(-row.topLeft); + final pixels = (await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + try { + final bytes = (await image.toByteData())!; + return [ + for (final point in [ + Offset(bounds.left + 2, 32), + Offset(bounds.right - 3, 32), + Offset(bounds.left + 2, 2), + ]) + Color.fromARGB( + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4 + 3, + ), + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4, + ), + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4 + 1, + ), + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4 + 2, + ), + ), + ]; + } finally { + image.dispose(); + } + }))!; + expect( + pixels.map((color) => color.toARGB32()), + [Colors.red, Colors.red, Colors.white].map((color) => color.toARGB32()), + ); + } + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(tester.binding.hasScheduledFrame, isFalse); + expect(tester.takeException(), isNull); + }); + testWidgets( 'Impeller lens refracts its own bounds and preserves foreground', (tester) async { diff --git a/lib/widgets/mornye_selection_pill.dart b/lib/widgets/mornye_selection_pill.dart index eb83d06f..6ea8c399 100644 --- a/lib/widgets/mornye_selection_pill.dart +++ b/lib/widgets/mornye_selection_pill.dart @@ -1,9 +1,16 @@ +import 'dart:async'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:spotiflac_android/widgets/mornye_liquid_backdrop.dart'; +Future? _selectionMaskProgram; +const _heldWidthGrowth = 0.28; +const _heldHeightGrowth = 0.24; +const _pillRadius = 32.0; + /// Selection with a small, transient refractive lens while held. Labels are /// rendered once above the lens; it samples the bar's backdrop on the GPU. class MornyeSelectionPill extends StatefulWidget { @@ -37,7 +44,8 @@ class MornyeSelectionPill extends StatefulWidget { State createState() => _MornyeSelectionPillState(); } -class _MornyeSelectionPillState extends State { +class _MornyeSelectionPillState extends State + with SingleTickerProviderStateMixin { int? _dragIndex; bool _dragCancelled = false; bool _pressed = false; @@ -45,10 +53,50 @@ class _MornyeSelectionPillState extends State { double? _dragAlignment; int? _activePointer; final _foregroundKey = GlobalKey(); + late final AnimationController _pressController; + late final CurvedAnimation _pressAnimation; + ui.FragmentProgram? _maskProgram; + + @override + void initState() { + super.initState(); + _pressController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + _pressAnimation = CurvedAnimation( + parent: _pressController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )..addListener(() => setState(() {})); + if (widget.selectionColor != null) unawaited(_loadMaskProgram()); + } + + Future _loadMaskProgram() async { + try { + final program = await (_selectionMaskProgram ??= + ui.FragmentProgram.fromAsset( + 'assets/shaders/mornye_selection_mask.frag', + )); + if (mounted) setState(() => _maskProgram = program); + } catch (error) { + debugPrint('Mornye selection mask unavailable: $error'); + } + } + + @override + void dispose() { + _pressAnimation.dispose(); + _pressController.dispose(); + super.dispose(); + } @override void didUpdateWidget(MornyeSelectionPill oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.selectionColor == null && widget.selectionColor != null) { + unawaited(_loadMaskProgram()); + } if (oldWidget.labels.length != widget.labels.length || oldWidget.selectedIndex != widget.selectedIndex) { _dragIndex = null; @@ -113,10 +161,12 @@ class _MornyeSelectionPillState extends State { ? count - visual - 1 : visual; }); + _pressController.forward(); }, onPointerUp: (event) { if (event.pointer != _activePointer) return; _activePointer = null; + _pressController.reverse(); setState(() { _pressed = false; _pressedIndex = null; @@ -126,6 +176,7 @@ class _MornyeSelectionPillState extends State { onPointerCancel: (event) { if (event.pointer != _activePointer) return; _activePointer = null; + _pressController.reverse(); setState(() { _dragCancelled = true; _pressed = false; @@ -187,7 +238,7 @@ class _MornyeSelectionPillState extends State { ? 0.12 : 0.08, ), - borderRadius: BorderRadius.circular(32), + borderRadius: BorderRadius.circular(_pillRadius), border: Border.all( color: scheme.onSurface.withValues(alpha: 0.05), width: 0.5, @@ -206,10 +257,11 @@ class _MornyeSelectionPillState extends State { ), ), if (lensEnabled && selected >= 0 && selected < count) - _interactionLens(selected, count), + _interactionLens(selected, count, _pressAnimation.value), _foreground( selected, count, + lensEnabled ? _pressAnimation.value : 0, Row( key: _foregroundKey, children: [ @@ -222,7 +274,9 @@ class _MornyeSelectionPillState extends State { child: Material( color: Colors.transparent, child: InkWell( - borderRadius: BorderRadius.circular(32), + borderRadius: BorderRadius.circular( + _pillRadius, + ), // The moving pill supplies press feedback. An // ink highlight would leave a colored patch on // the tab where the drag started. @@ -289,7 +343,12 @@ class _MornyeSelectionPillState extends State { return (1 - (index - position).abs()).clamp(0.0, 1.0); } - Widget _foreground(int selected, int count, Widget child) { + Widget _foreground( + int selected, + int count, + double pressProgress, + Widget child, + ) { final color = widget.selectionColor; if (color == null) return child; return TweenAnimationBuilder( @@ -309,6 +368,9 @@ class _MornyeSelectionPillState extends State { ? -alignment : alignment, count: count, + scaleX: 1 + _heldWidthGrowth * pressProgress, + scaleY: 1 + _heldHeightGrowth * pressProgress, + program: _maskProgram, color: selected >= 0 && selected < count ? color : color.withValues(alpha: 0), @@ -318,7 +380,11 @@ class _MornyeSelectionPillState extends State { ); } - Widget _interactionLens(int selected, int count) => Positioned.fill( + Widget _interactionLens( + int selected, + int count, + double progress, + ) => Positioned.fill( child: IgnorePointer( child: AnimatedAlign( duration: _dragAlignment != null @@ -329,42 +395,37 @@ class _MornyeSelectionPillState extends State { _dragAlignment ?? (count == 1 ? 0 : -1 + 2 * selected / (count - 1)), 0, ), - child: TweenAnimationBuilder( - tween: Tween(end: _pressed ? 1 : 0), - duration: const Duration(milliseconds: 240), - curve: Curves.easeOutCubic, - builder: (context, progress, child) => FractionallySizedBox( - widthFactor: 1 / count, - heightFactor: 1, - child: Transform.scale( - scaleX: 1 + 0.28 * progress, - scaleY: 1 + 0.24 * progress, - child: ClipRRect( - borderRadius: BorderRadius.circular(32), - child: LiquidGlassBatch.exclude( - child: MornyeLiquidBackdrop( - borderRadius: BorderRadius.circular(32), - clarity: 1, - interaction: true, - progress: progress, - blurSigma: 0, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - color: Colors.white.withValues( - alpha: - progress * - (Theme.of(context).brightness == Brightness.light - ? 0.24 - : 0.08), - ), - border: Border.all( - color: Colors.white.withValues(alpha: 0.6 * progress), - width: 0.75, - ), + child: FractionallySizedBox( + widthFactor: 1 / count, + heightFactor: 1, + child: Transform.scale( + scaleX: 1 + _heldWidthGrowth * progress, + scaleY: 1 + _heldHeightGrowth * progress, + child: ClipRRect( + borderRadius: BorderRadius.circular(_pillRadius), + child: LiquidGlassBatch.exclude( + child: MornyeLiquidBackdrop( + borderRadius: BorderRadius.circular(_pillRadius), + clarity: 1, + interaction: true, + progress: progress, + blurSigma: 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_pillRadius), + color: Colors.white.withValues( + alpha: + progress * + (Theme.of(context).brightness == Brightness.light + ? 0.24 + : 0.08), + ), + border: Border.all( + color: Colors.white.withValues(alpha: 0.6 * progress), + width: 0.75, ), - child: const SizedBox.expand(), ), + child: const SizedBox.expand(), ), ), ), @@ -378,16 +439,36 @@ class _MornyeSelectionPillState extends State { /// Paint only icons and labels with the sliding selection color. Badge fills /// and counters can remain outside this widget and retain their own contrast. -class MornyeSelectionForeground extends StatelessWidget { +class MornyeSelectionForeground extends StatefulWidget { const MornyeSelectionForeground({super.key, required this.child}); final Widget child; + @override + State createState() => + _MornyeSelectionForegroundState(); +} + +class _MornyeSelectionForegroundState extends State { + ui.FragmentProgram? _program; + ui.FragmentShader? _shader; + + @override + void dispose() { + _shader?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final scope = context .dependOnInheritedWidgetOfExactType<_SelectionForegroundScope>(); - if (scope == null) return child; + if (scope == null) return widget.child; + if (_program != scope.program) { + _shader?.dispose(); + _program = scope.program; + _shader = _program?.fragmentShader(); + } return ShaderMask( blendMode: BlendMode.srcATop, shaderCallback: (bounds) { @@ -396,21 +477,46 @@ class MornyeSelectionForeground extends StatelessWidget { final box = context.findRenderObject()! as RenderBox; final width = row.size.width / scope.count; final left = (scope.alignment + 1) * (row.size.width - width) / 2; - // Convert from the bar so scaled icons and nested badges still share - // exactly the same moving selection boundary. - final start = box.globalToLocal(row.localToGlobal(Offset(left, 0))); - final end = box.globalToLocal( - row.localToGlobal(Offset(left + width, 0)), + final lens = Rect.fromCenter( + center: Offset(left + width / 2, row.size.height / 2), + width: width * scope.scaleX, + height: row.size.height * scope.scaleY, ); + // Use the same rounded, expanding bounds as the glass, including the + // inverse scale of each icon. A one-tab rectangular mask cuts through + // the foreground before it reaches the held lens's curved edge. + final local = Rect.fromPoints( + box.globalToLocal(row.localToGlobal(lens.topLeft)), + box.globalToLocal(row.localToGlobal(lens.bottomRight)), + ); + final shader = _shader; + if (shader != null) { + shader + ..setFloat(0, local.left) + ..setFloat(1, local.top) + ..setFloat(2, local.width) + ..setFloat(3, local.height) + ..setFloat(4, local.width / width) + ..setFloat(5, local.height / row.size.height) + ..setFloat( + 6, + math.min(_pillRadius, math.min(width, row.size.height) / 2), + ) + ..setFloat(7, scope.color.r) + ..setFloat(8, scope.color.g) + ..setFloat(9, scope.color.b) + ..setFloat(10, scope.color.a); + return shader; + } return ui.Gradient.linear( - Offset(start.dx, 0), - Offset(end.dx, 0), + Offset(local.left, 0), + Offset(local.right, 0), [scope.color, scope.color], null, TileMode.decal, ); }, - child: child, + child: widget.child, ); } } @@ -420,6 +526,9 @@ class _SelectionForegroundScope extends InheritedWidget { required this.rowKey, required this.alignment, required this.count, + required this.scaleX, + required this.scaleY, + required this.program, required this.color, required super.child, }); @@ -427,12 +536,18 @@ class _SelectionForegroundScope extends InheritedWidget { final GlobalKey rowKey; final double alignment; final int count; + final double scaleX; + final double scaleY; + final ui.FragmentProgram? program; final Color color; @override bool updateShouldNotify(_SelectionForegroundScope oldWidget) => alignment != oldWidget.alignment || count != oldWidget.count || + scaleX != oldWidget.scaleX || + scaleY != oldWidget.scaleY || + program != oldWidget.program || color != oldWidget.color || rowKey != oldWidget.rowKey; } diff --git a/pubspec.yaml b/pubspec.yaml index edd03d4b..05e7874a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -120,6 +120,9 @@ flutter: uses-material-design: true generate: true + shaders: + - assets/shaders/mornye_selection_mask.frag + assets: - assets/images/ - assets/fonts/Inter-LICENSE.txt diff --git a/test/mornye_glass_interaction_test.dart b/test/mornye_glass_interaction_test.dart index 561e34d3..f87c7ed7 100644 --- a/test/mornye_glass_interaction_test.dart +++ b/test/mornye_glass_interaction_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -9,6 +11,82 @@ import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/widgets/mornye_selection_pill.dart'; void main() { + testWidgets('selection color follows rounded pill corners', (tester) async { + final capture = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RepaintBoundary( + key: capture, + child: SizedBox( + width: 200, + child: MornyeSelectionPill( + labels: const ['A', 'B'], + selectedIndex: 0, + onChanged: (_) {}, + padding: EdgeInsets.zero, + selectionColor: Colors.red, + itemBuilder: (_, _, _) => const SizedBox( + height: 64, + child: ColoredBox(color: Colors.white), + ), + ), + ), + ), + ), + ), + ); + await tester.runAsync( + () => ui.FragmentProgram.fromAsset( + 'assets/shaders/mornye_selection_mask.frag', + ), + ); + await tester.pumpAndSettle(); + final colors = (await tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + try { + final bytes = (await image.toByteData())!; + return [ + for (final point in [ + const Offset(2, 2), + const Offset(20, 32), + const Offset(98, 2), + const Offset(150, 32), + ]) + Color.fromARGB( + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4 + 3, + ), + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4, + ), + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4 + 1, + ), + bytes.getUint8( + (point.dy.toInt() * image.width + point.dx.toInt()) * 4 + 2, + ), + ), + ]; + } finally { + image.dispose(); + } + }))!; + expect( + colors.map((color) => color.toARGB32()), + [ + Colors.white, + Colors.red, + Colors.white, + Colors.white, + ].map((color) => color.toARGB32()), + ); + expect(tester.takeException(), isNull); + }); + for (final direction in TextDirection.values) { testWidgets('tab dragging previews, commits and cancels ($direction)', ( tester,