Skip to content

Commit 7677aac

Browse files
fix SearchAnchor disposing SearchController while it is still used (flutter#155219)
fixes flutter#155180 New behaviour: SearchAnchor now closes the menu when itself is disposed while the menu is still open. This is the behaviour of `MenuAnchor`/`OverlayPortal`.
1 parent db76401 commit 7677aac

File tree

2 files changed

+172
-4
lines changed

2 files changed

+172
-4
lines changed

packages/flutter/lib/src/material/search_anchor.dart

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ class _SearchAnchorState extends State<SearchAnchor> {
371371
bool get _viewIsOpen => !_anchorIsVisible;
372372
SearchController? _internalSearchController;
373373
SearchController get _searchController => widget.searchController ?? (_internalSearchController ??= SearchController());
374+
_SearchViewRoute? _route;
374375

375376
@override
376377
void initState() {
@@ -401,15 +402,25 @@ class _SearchAnchorState extends State<SearchAnchor> {
401402

402403
@override
403404
void dispose() {
404-
super.dispose();
405405
widget.searchController?._detach(this);
406406
_internalSearchController?._detach(this);
407-
_internalSearchController?.dispose();
407+
final bool usingExternalController = widget.searchController != null;
408+
if (_route?.navigator != null) {
409+
_route?._dismiss(
410+
disposeController: !usingExternalController,
411+
);
412+
if (usingExternalController) {
413+
_internalSearchController?.dispose();
414+
}
415+
} else {
416+
_internalSearchController?.dispose();
417+
}
418+
super.dispose();
408419
}
409420

410421
void _openView() {
411422
final NavigatorState navigator = Navigator.of(context);
412-
navigator.push(_SearchViewRoute(
423+
_route = _SearchViewRoute(
413424
viewOnChanged: widget.viewOnChanged,
414425
viewOnSubmitted: widget.viewOnSubmitted,
415426
viewLeading: widget.viewLeading,
@@ -436,7 +447,8 @@ class _SearchAnchorState extends State<SearchAnchor> {
436447
capturedThemes: InheritedTheme.capture(from: context, to: navigator.context),
437448
textInputAction: widget.textInputAction,
438449
keyboardType: widget.keyboardType,
439-
));
450+
);
451+
navigator.push(_route!);
440452
}
441453

442454
void _closeView(String? selectedText) {
@@ -542,6 +554,7 @@ class _SearchViewRoute extends PopupRoute<_SearchViewRoute> {
542554
final TextInputType? keyboardType;
543555
CurvedAnimation? curvedAnimation;
544556
CurvedAnimation? viewFadeOnIntervalCurve;
557+
bool willDisposeSearchController = false;
545558

546559
@override
547560
Color? get barrierColor => Colors.transparent;
@@ -585,10 +598,20 @@ class _SearchViewRoute extends PopupRoute<_SearchViewRoute> {
585598
return super.didPop(result);
586599
}
587600

601+
void _dismiss({required bool disposeController}) {
602+
willDisposeSearchController = disposeController;
603+
if (isActive) {
604+
navigator?.removeRoute(this);
605+
}
606+
}
607+
588608
@override
589609
void dispose() {
590610
curvedAnimation?.dispose();
591611
viewFadeOnIntervalCurve?.dispose();
612+
if (willDisposeSearchController) {
613+
searchController.dispose();
614+
}
592615
super.dispose();
593616
}
594617

packages/flutter/test/material/search_anchor_test.dart

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3419,6 +3419,151 @@ void main() {
34193419
expect(find.byType(Placeholder), findsOneWidget);
34203420
});
34213421
});
3422+
3423+
testWidgets('SearchAnchor does not dispose external SearchController', (WidgetTester tester) async {
3424+
final SearchController controller = SearchController();
3425+
addTearDown(controller.dispose);
3426+
await tester.pumpWidget(
3427+
MaterialApp(
3428+
home: Material(
3429+
child: SearchAnchor(
3430+
searchController: controller,
3431+
builder: (BuildContext context, SearchController controller) {
3432+
return IconButton(
3433+
onPressed: () async {
3434+
controller.openView();
3435+
},
3436+
icon: const Icon(Icons.search),
3437+
);
3438+
},
3439+
suggestionsBuilder: (BuildContext context, SearchController controller) {
3440+
return <Widget>[];
3441+
},
3442+
),
3443+
),
3444+
));
3445+
3446+
await tester.tap(find.byIcon(Icons.search));
3447+
await tester.pumpAndSettle();
3448+
await tester.pumpWidget(
3449+
const MaterialApp(
3450+
home: Material(
3451+
child: Text('disposed'),
3452+
),
3453+
));
3454+
expect(tester.takeException(), isNull);
3455+
ChangeNotifier.debugAssertNotDisposed(controller);
3456+
});
3457+
3458+
testWidgets('SearchAnchor gracefully closes its search view when disposed', (WidgetTester tester) async {
3459+
bool disposed = false;
3460+
late StateSetter setState;
3461+
await tester.pumpWidget(
3462+
MaterialApp(
3463+
home: Material(
3464+
child: StatefulBuilder(
3465+
builder: (BuildContext context, StateSetter stateSetter) {
3466+
setState = stateSetter;
3467+
if (disposed) {
3468+
return const Text('disposed');
3469+
}
3470+
return SearchAnchor(
3471+
builder: (BuildContext context, SearchController controller) {
3472+
return IconButton(
3473+
onPressed: () async {
3474+
controller.openView();
3475+
},
3476+
icon: const Icon(Icons.search),
3477+
);
3478+
},
3479+
suggestionsBuilder: (BuildContext context, SearchController controller) {
3480+
return <Widget>[
3481+
const Text('suggestion'),
3482+
];
3483+
},
3484+
);
3485+
}
3486+
),
3487+
),
3488+
),
3489+
);
3490+
3491+
await tester.tap(find.byIcon(Icons.search));
3492+
await tester.pumpAndSettle();
3493+
setState(() {
3494+
disposed = true;
3495+
});
3496+
await tester.pump();
3497+
// The search menu starts to close but is not disposed yet.
3498+
final EditableText editableText = tester.widget(find.byType(EditableText));
3499+
final TextEditingController controller = editableText.controller;
3500+
ChangeNotifier.debugAssertNotDisposed(controller);
3501+
3502+
await tester.pumpAndSettle();
3503+
// The search menu and the internal search controller are now disposed.
3504+
expect(tester.takeException(), isNull);
3505+
expect(find.byType(TextField), findsNothing);
3506+
FlutterError? error;
3507+
try {
3508+
ChangeNotifier.debugAssertNotDisposed(controller);
3509+
} on FlutterError catch (e) {
3510+
error = e;
3511+
}
3512+
expect(error, isNotNull);
3513+
expect(error, isFlutterError);
3514+
expect(
3515+
error!.toStringDeep(),
3516+
equalsIgnoringHashCodes(
3517+
'FlutterError\n'
3518+
' A SearchController was used after being disposed.\n'
3519+
' Once you have called dispose() on a SearchController, it can no\n'
3520+
' longer be used.\n',
3521+
),
3522+
);
3523+
});
3524+
3525+
// Regression test for https://github.com/flutter/flutter/issues/155180.
3526+
testWidgets('disposing SearchAnchor during search view exit animation does not crash',
3527+
(WidgetTester tester) async {
3528+
final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>();
3529+
await tester.pumpWidget(
3530+
MaterialApp(
3531+
navigatorKey: key,
3532+
home: Material(
3533+
child: SearchAnchor(
3534+
builder: (BuildContext context, SearchController controller) {
3535+
return IconButton(
3536+
onPressed: () async {
3537+
controller.openView();
3538+
},
3539+
icon: const Icon(Icons.search),
3540+
);
3541+
},
3542+
suggestionsBuilder: (BuildContext context, SearchController controller) {
3543+
return <Widget>[
3544+
const Text('suggestion'),
3545+
];
3546+
},
3547+
),
3548+
),
3549+
),
3550+
);
3551+
3552+
await tester.tap(find.byIcon(Icons.search));
3553+
await tester.pumpAndSettle();
3554+
key.currentState!.pop();
3555+
await tester.pump();
3556+
await tester.pumpWidget(
3557+
MaterialApp(
3558+
navigatorKey: key,
3559+
home: const Material(
3560+
child: Text('disposed'),
3561+
),
3562+
),
3563+
);
3564+
await tester.pump();
3565+
expect(tester.takeException(), isNull);
3566+
});
34223567
}
34233568

34243569
Future<void> checkSearchBarDefaults(WidgetTester tester, ColorScheme colorScheme, Material material) async {

0 commit comments

Comments
 (0)