From 18f26792b1415a2cac7a0ca558119e7015f7a308 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:30:32 +0300 Subject: [PATCH] motion: toasts animate out and give their height back; the welcome tour slides in the direction of travel (#1645) --- .../onboarding/welcome_tour_dialog.dart | 28 +++++++++-- lib/shared/widgets/app_toast.dart | 33 +++++++++---- .../onboarding/welcome_tour_dialog_test.dart | 40 ++++++++++++++++ test/shared/app_toast_test.dart | 46 +++++++++++++++++++ 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/lib/features/onboarding/welcome_tour_dialog.dart b/lib/features/onboarding/welcome_tour_dialog.dart index d2d624c2..1d36b2d4 100644 --- a/lib/features/onboarding/welcome_tour_dialog.dart +++ b/lib/features/onboarding/welcome_tour_dialog.dart @@ -146,15 +146,26 @@ class _WelcomeTourDialogState extends State { super.dispose(); } + /// Whether the last move went to a later step: the new slide then comes in + /// from the right and the old one leaves to the left, and the other way + /// round for Back or an earlier dot (#1645). + var _forward = true; + void _goToStep(int step) { if (step >= 0 && step < _totalSteps && step != _currentStep) { - setState(() => _currentStep = step); + setState(() { + _forward = step > _currentStep; + _currentStep = step; + }); } } void _next() { if (_currentStep < _totalSteps - 1) { - setState(() => _currentStep++); + setState(() { + _forward = true; + _currentStep++; + }); } else { _finish(); } @@ -162,7 +173,10 @@ class _WelcomeTourDialogState extends State { void _previous() { if (_currentStep > 0) { - setState(() => _currentStep--); + setState(() { + _forward = false; + _currentStep--; + }); } } @@ -287,11 +301,17 @@ onPressed: _finish, switchInCurve: context.motionCurve(QueryaMotion.enter), switchOutCurve: context.motionCurve(QueryaMotion.exit), transitionBuilder: (child, animation) { + // The incoming slide starts on the side we move towards; + // the outgoing one (its animation runs back to 0) ends + // on the other side. + final incoming = + child.key == material.ValueKey(_currentStep); + final side = (_forward ? 1.0 : -1.0) * (incoming ? 1 : -1); return material.FadeTransition( opacity: animation, child: material.SlideTransition( position: material.Tween( - begin: const material.Offset(0.04, 0), + begin: material.Offset(0.04 * side, 0), end: material.Offset.zero, ).animate(animation), child: child, diff --git a/lib/shared/widgets/app_toast.dart b/lib/shared/widgets/app_toast.dart index 3a92e16a..ac3c7184 100644 --- a/lib/shared/widgets/app_toast.dart +++ b/lib/shared/widgets/app_toast.dart @@ -18,7 +18,8 @@ enum AppToastVariant { /// Toasts share one entry in the root overlay and stack upwards. Each one /// fades and slides in over [QueryaMotion.standard] (instantly under reduced /// motion), stays for [showDuration] and closes itself, or earlier through its -/// close button. +/// close button. Closing plays the entry backwards and gives its height back +/// gradually, so the toasts above it slide down instead of jumping. void showAppToast({ required material.BuildContext context, required String message, @@ -129,8 +130,8 @@ class _ToastViewState extends material.State<_ToastView> void initState() { super.initState(); final d = widget.data; - // The controller only plays the entry; a timer ends the toast, so a test - // that settles frames does not run its whole lifetime. + // The controller plays the entry and the exit; a timer starts the exit, + // so a test that settles frames does not run the toast's whole lifetime. _controller = material.AnimationController( vsync: this, duration: d.enter == Duration.zero ? const Duration(milliseconds: 1) : d.enter, @@ -145,7 +146,15 @@ class _ToastViewState extends material.State<_ToastView> void _close() { if (_closed) return; _closed = true; - widget.onClosed(); + _timer.cancel(); + if (widget.data.enter == Duration.zero) { + widget.onClosed(); // reduced motion: gone at once + return; + } + // Out the way it came in (#1645); the list drops it when that is done. + _controller.reverse().whenCompleteOrCancel(() { + if (mounted) widget.onClosed(); + }); } @override @@ -170,11 +179,17 @@ class _ToastViewState extends material.State<_ToastView> return material.AnimatedBuilder( animation: _in, - builder: (context, child) => material.Opacity( - opacity: _in.value, - child: material.Transform.translate( - offset: material.Offset(0, (1 - _in.value) * 12), - child: child, + // The height follows the same animation, so neighbours move smoothly + // when a toast arrives or leaves. + builder: (context, child) => material.SizeTransition( + sizeFactor: _in, + axisAlignment: -1, + child: material.Opacity( + opacity: _in.value, + child: material.Transform.translate( + offset: material.Offset(0, (1 - _in.value) * 12), + child: child, + ), ), ), child: material.Padding( diff --git a/test/features/onboarding/welcome_tour_dialog_test.dart b/test/features/onboarding/welcome_tour_dialog_test.dart index e99497f0..374480ab 100644 --- a/test/features/onboarding/welcome_tour_dialog_test.dart +++ b/test/features/onboarding/welcome_tour_dialog_test.dart @@ -109,6 +109,46 @@ void main() { expect(find.text('Compound Filter Bar & Quick Calc'), findsOneWidget); }); + testWidgets('Back slides the earlier step in from the left (#1645)', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const material.Scaffold(body: WelcomeTourDialog()), + ), + ); + await tester.pumpAndSettle(); + + double dxOf(int step) => tester + .widget( + find + .ancestor( + of: find.byKey(ValueKey(step)), + matching: find.byType(material.SlideTransition), + ) + .first, + ) + .position + .value + .dx; + + // Forward: the new step comes from the right, the old one leaves left. + await tester.tap(find.byKey(const Key('welcome_tour_next_button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(dxOf(1), greaterThan(0)); + expect(dxOf(0), lessThan(0)); + await tester.pumpAndSettle(); + + // Back: mirrored. + await tester.tap(find.byKey(const Key('welcome_tour_prev_button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + expect(dxOf(0), lessThan(0)); + expect(dxOf(1), greaterThan(0)); + await tester.pumpAndSettle(); + expect(find.text('Step 1 of 6'), findsOneWidget); + }); + testWidgets('WelcomeTourDialog launches demo playground on action button tap', (tester) async { var demoLaunched = false; diff --git a/test/shared/app_toast_test.dart b/test/shared/app_toast_test.dart index cbad963a..8cef4bd7 100644 --- a/test/shared/app_toast_test.dart +++ b/test/shared/app_toast_test.dart @@ -115,4 +115,50 @@ void main() { await tester.pump(const Duration(seconds: 6)); await tester.pumpAndSettle(); }); + + testWidgets('closing fades the toast out before it is removed (#1645)', + (tester) async { + await tester.pumpWidget( + _app( + onShow: (context) => showAppToast(context: context, message: 'Leaving'), + ), + ); + await tester.tap(find.text('Show')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + await tester.tap(find.byIcon(material.Icons.close_rounded)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + expect(find.text('Leaving'), findsOneWidget); + final opacity = tester.widget( + find + .ancestor( + of: find.text('Leaving'), + matching: find.byType(material.Opacity), + ) + .first, + ); + expect(opacity.opacity, lessThan(1)); + + await tester.pumpAndSettle(); + expect(find.text('Leaving'), findsNothing); + }); + + testWidgets('under reduced motion a closed toast goes at once', (tester) async { + await tester.pumpWidget( + _app( + disableAnimations: true, + onShow: (context) => showAppToast(context: context, message: 'Quick'), + ), + ); + await tester.tap(find.text('Show')); + await tester.pump(); + + await tester.tap(find.byIcon(material.Icons.close_rounded)); + await tester.pump(); + + expect(find.text('Quick'), findsNothing); + }); }