Skip to content

Commit 4ca094c

Browse files
fix(dropdown): ensure enter animation interpolates and unblock trigger during exit delay in QueryaDropdown (Closes #902)
1 parent 2f4b95a commit 4ca094c

4 files changed

Lines changed: 234 additions & 16 deletions

File tree

‎lib/features/settings/theme_picker_button.dart‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
8080
material.ValueNotifier<bool>(false);
8181
bool _triggerHovered = false;
8282
bool _closingWithExit = false;
83+
int _exitGeneration = 0;
8384
String? _previewThemeId;
8485
String? _previewThemeLabel;
8586
QueryaTheme? _previewTheme;
@@ -104,16 +105,25 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
104105
super.dispose();
105106
}
106107

108+
void _cancelExit() {
109+
if (_closingWithExit) {
110+
_exitGeneration++;
111+
_closingWithExit = false;
112+
_menuOpen.value = true;
113+
}
114+
}
115+
107116
/// Plays exit fade-slide, then removes the [MenuAnchor] overlay (#499).
108117
Future<void> _closeWithExit() async {
109118
if (!_controller.isOpen || _closingWithExit) return;
110119
_closingWithExit = true;
111120
_menuOpen.value = false;
121+
final gen = ++_exitGeneration;
112122
final duration = context.motionDuration(QueryaMotion.standard);
113123
if (duration > QueryaMotion.instant) {
114124
await Future<void>.delayed(duration);
115125
}
116-
if (!mounted) return;
126+
if (!mounted || gen != _exitGeneration) return;
117127
if (_controller.isOpen) {
118128
_controller.close();
119129
}
@@ -217,10 +227,12 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
217227
final anchor = material.MenuAnchor(
218228
controller: _controller,
219229
onOpen: () {
230+
_exitGeneration++;
220231
_closingWithExit = false;
221232
_menuOpen.value = true;
222233
},
223234
onClose: () {
235+
_exitGeneration++;
224236
_closingWithExit = false;
225237
_menuOpen.value = false;
226238
},
@@ -461,7 +473,9 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
461473
child: material.InkWell(
462474
onTap: _enabled
463475
? () {
464-
if (controller.isOpen) {
476+
if (_closingWithExit) {
477+
_cancelExit();
478+
} else if (controller.isOpen) {
465479
unawaited(_closeWithExit());
466480
} else {
467481
_clearSearch();
@@ -498,7 +512,7 @@ class _ThemePickerButtonState extends material.State<ThemePickerButton> {
498512
}
499513

500514
/// Enter/exit fade-slide for theme menu body while the overlay stays mounted.
501-
class _ThemePickerMenuEnter extends material.StatelessWidget {
515+
class _ThemePickerMenuEnter extends material.StatefulWidget {
502516
const _ThemePickerMenuEnter({
503517
required this.openNotifier,
504518
required this.child,
@@ -507,24 +521,48 @@ class _ThemePickerMenuEnter extends material.StatelessWidget {
507521
final material.ValueNotifier<bool> openNotifier;
508522
final material.Widget child;
509523

524+
@override
525+
material.State<_ThemePickerMenuEnter> createState() =>
526+
_ThemePickerMenuEnterState();
527+
}
528+
529+
class _ThemePickerMenuEnterState
530+
extends material.State<_ThemePickerMenuEnter> {
531+
bool _entered = false;
532+
533+
@override
534+
void initState() {
535+
super.initState();
536+
material.WidgetsBinding.instance.addPostFrameCallback((_) {
537+
if (mounted) {
538+
setState(() {
539+
_entered = true;
540+
});
541+
}
542+
});
543+
}
544+
510545
@override
511546
material.Widget build(material.BuildContext context) {
512547
final duration = context.motionDuration(QueryaMotion.standard);
513548
final enter = context.motionCurve(QueryaMotion.enter);
514549
final exit = context.motionCurve(QueryaMotion.exit);
550+
final instant = duration == QueryaMotion.instant;
551+
515552
return material.ValueListenableBuilder<bool>(
516-
valueListenable: openNotifier,
553+
valueListenable: widget.openNotifier,
517554
builder: (context, open, _) {
518-
final curve = open ? enter : exit;
555+
final isOpen = (open && _entered) || (open && instant);
556+
final curve = isOpen ? enter : exit;
519557
return material.AnimatedSlide(
520-
offset: open ? material.Offset.zero : const material.Offset(0, -0.04),
558+
offset: isOpen ? material.Offset.zero : const material.Offset(0, -0.04),
521559
duration: duration,
522560
curve: curve,
523561
child: material.AnimatedOpacity(
524-
opacity: open ? 1 : 0,
562+
opacity: isOpen ? 1 : 0,
525563
duration: duration,
526564
curve: curve,
527-
child: child,
565+
child: widget.child,
528566
),
529567
);
530568
},

‎lib/shared/widgets/querya_dropdown.dart‎

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
7070
List<QueryaDropdownItem<T>>? _cachedMenuItems;
7171
T? _cachedMenuValue;
7272
var _closingWithExit = false;
73+
var _exitGeneration = 0;
7374

7475
@override
7576
void initState() {
@@ -83,16 +84,25 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
8384
super.dispose();
8485
}
8586

87+
void _cancelExit() {
88+
if (_closingWithExit) {
89+
_exitGeneration++;
90+
_closingWithExit = false;
91+
_menuOpen.value = true;
92+
}
93+
}
94+
8695
/// Plays exit fade-slide, then removes the [MenuAnchor] overlay.
8796
Future<void> _closeWithExit() async {
8897
if (!_controller.isOpen || _closingWithExit) return;
8998
_closingWithExit = true;
9099
_menuOpen.value = false;
100+
final gen = ++_exitGeneration;
91101
final duration = context.motionDuration(QueryaMotion.standard);
92102
if (duration > QueryaMotion.instant) {
93103
await Future<void>.delayed(duration);
94104
}
95-
if (!mounted) return;
105+
if (!mounted || gen != _exitGeneration) return;
96106
if (_controller.isOpen) {
97107
_controller.close();
98108
}
@@ -231,7 +241,9 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
231241
child: material.InkWell(
232242
onTap: widget.enabled
233243
? () {
234-
if (controller.isOpen) {
244+
if (_closingWithExit) {
245+
_cancelExit();
246+
} else if (controller.isOpen) {
235247
unawaited(_closeWithExit());
236248
} else {
237249
controller.open();
@@ -264,11 +276,13 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
264276
final anchor = material.MenuAnchor(
265277
controller: _controller,
266278
onOpen: () {
279+
_exitGeneration++;
267280
_closingWithExit = false;
268281
_menuOpen.value = true;
269282
},
270283
onClose: () {
271284
// Outside-tap / focus loss: overlay already gone — snap state only.
285+
_exitGeneration++;
272286
_closingWithExit = false;
273287
_menuOpen.value = false;
274288
},
@@ -336,7 +350,7 @@ class _QueryaDropdownState<T> extends material.State<QueryaDropdown<T>> {
336350
///
337351
/// Exit only runs when the parent delays [MenuController.close] (item pick /
338352
/// trigger). Outside-tap removes the overlay immediately (snap).
339-
class _QueryaDropdownMenuEnter extends material.StatelessWidget {
353+
class _QueryaDropdownMenuEnter extends material.StatefulWidget {
340354
const _QueryaDropdownMenuEnter({
341355
required this.openNotifier,
342356
required this.child,
@@ -345,24 +359,48 @@ class _QueryaDropdownMenuEnter extends material.StatelessWidget {
345359
final material.ValueNotifier<bool> openNotifier;
346360
final material.Widget child;
347361

362+
@override
363+
material.State<_QueryaDropdownMenuEnter> createState() =>
364+
_QueryaDropdownMenuEnterState();
365+
}
366+
367+
class _QueryaDropdownMenuEnterState
368+
extends material.State<_QueryaDropdownMenuEnter> {
369+
bool _entered = false;
370+
371+
@override
372+
void initState() {
373+
super.initState();
374+
material.WidgetsBinding.instance.addPostFrameCallback((_) {
375+
if (mounted) {
376+
setState(() {
377+
_entered = true;
378+
});
379+
}
380+
});
381+
}
382+
348383
@override
349384
material.Widget build(material.BuildContext context) {
350385
final duration = context.motionDuration(QueryaMotion.standard);
351386
final enter = context.motionCurve(QueryaMotion.enter);
352387
final exit = context.motionCurve(QueryaMotion.exit);
388+
final instant = duration == QueryaMotion.instant;
389+
353390
return material.ValueListenableBuilder<bool>(
354-
valueListenable: openNotifier,
391+
valueListenable: widget.openNotifier,
355392
builder: (context, open, _) {
356-
final curve = open ? enter : exit;
393+
final isOpen = (open && _entered) || (open && instant);
394+
final curve = isOpen ? enter : exit;
357395
return material.AnimatedSlide(
358-
offset: open ? material.Offset.zero : const material.Offset(0, -0.04),
396+
offset: isOpen ? material.Offset.zero : const material.Offset(0, -0.04),
359397
duration: duration,
360398
curve: curve,
361399
child: material.AnimatedOpacity(
362-
opacity: open ? 1 : 0,
400+
opacity: isOpen ? 1 : 0,
363401
duration: duration,
364402
curve: curve,
365-
child: child,
403+
child: widget.child,
366404
),
367405
);
368406
},

‎test/features/settings/theme_picker_button_test.dart‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,73 @@ void main() {
635635
await tester.pumpAndSettle();
636636
expect(find.byType(material.ListView), findsNothing);
637637
});
638+
639+
testWidgets('rapid trigger toggle during exit delay cancels close and keeps theme picker open',
640+
(tester) async {
641+
final themes = _fakeThemes(5);
642+
643+
await tester.pumpWidget(
644+
queryaThemeTestShell(
645+
child: material.Scaffold(
646+
body: ThemePickerButton(
647+
themes: themes,
648+
selectedThemeId: 'theme-0',
649+
onSelected: (_) {},
650+
),
651+
),
652+
),
653+
);
654+
await tester.pump();
655+
656+
// Open menu
657+
await tester.tap(find.text('Theme 00'));
658+
await tester.pumpAndSettle();
659+
expect(find.byType(material.ListView), findsOneWidget);
660+
661+
// Tap trigger to close (starts exit delay)
662+
await tester.tap(find.text('Theme 00').first);
663+
await tester.pump(const Duration(milliseconds: 30));
664+
expect(find.byType(material.ListView), findsOneWidget);
665+
666+
// Rapid tap trigger again during exit delay -> cancels close and reopens
667+
await tester.tap(find.text('Theme 00').first);
668+
await tester.pumpAndSettle();
669+
670+
// Menu must remain open, ListView must be visible
671+
expect(find.byType(material.ListView), findsOneWidget);
672+
});
673+
674+
testWidgets('menu enter mounts initially with 0 opacity before animating to 1',
675+
(tester) async {
676+
final themes = _fakeThemes(5);
677+
678+
await tester.pumpWidget(
679+
queryaThemeTestShell(
680+
child: material.Scaffold(
681+
body: ThemePickerButton(
682+
themes: themes,
683+
selectedThemeId: 'theme-0',
684+
onSelected: (_) {},
685+
),
686+
),
687+
),
688+
);
689+
await tester.pump();
690+
691+
await tester.tap(find.text('Theme 00'));
692+
await tester.pump(); // Frame 1: mounted in overlay with opacity 0
693+
final opacityWidget = tester.widget<material.AnimatedOpacity>(
694+
find.byType(material.AnimatedOpacity).first,
695+
);
696+
expect(opacityWidget.opacity, 0.0);
697+
698+
await tester.pump(); // Frame 2: post-frame callback runs, animates to 1.0
699+
await tester.pumpAndSettle();
700+
final settledOpacity = tester.widget<material.AnimatedOpacity>(
701+
find.byType(material.AnimatedOpacity).first,
702+
);
703+
expect(settledOpacity.opacity, 1.0);
704+
});
638705
});
639706

640707
group('filterThemeDefinitions metadata', () {

0 commit comments

Comments
 (0)