From 8363e78280a91b048a8efc73606669d1d40a3ebf Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Tue, 26 Mar 2024 00:58:11 +0200 Subject: [PATCH] Fix `SearchAnchor` triggers unnecessary suggestions builder calls (#143443) fixes [`SearchAnchor` triggers extra search operations](https://github.com/flutter/flutter/issues/139880) ### Code sample
expand to view the code sample ```dart import 'package:flutter/material.dart'; Future> createFuture() async { return List.generate(1000, (index) => "Hello World!"); } void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp( title: 'Flutter Demo', home: MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { final SearchController controller = SearchController(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ SearchAnchor( searchController: controller, suggestionsBuilder: (suggestionsContext, controller) { final resultFuture = createFuture(); return [ FutureBuilder( future: resultFuture, builder: ((context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const LinearProgressIndicator(); } final result = snapshot.data; if (result == null) { return const LinearProgressIndicator(); } return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: result.length, itemBuilder: (BuildContext context, int index) { final root = result[index]; return ListTile( leading: const Icon(Icons.article), title: Text(root), subtitle: Text( root, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(suggestionsContext) .colorScheme .onSurfaceVariant, ), ), onTap: () {}, ); }, ); }), ), ]; }, builder: (BuildContext context, SearchController controller) { return IconButton( onPressed: () { controller.openView(); }, icon: const Icon(Icons.search), ); }, ), ], ), ), ); } } ```
### Before https://github.com/flutter/flutter/assets/48603081/69f6dfdc-9f92-4d2e-8a3e-984fce25f9e4 ### After https://github.com/flutter/flutter/assets/48603081/be105e2c-51d8-4cb0-a75b-f5f41d948e5e --- .../lib/src/material/search_anchor.dart | 28 ++++-- .../test/material/search_anchor_test.dart | 98 +++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/material/search_anchor.dart b/packages/flutter/lib/src/material/search_anchor.dart index 90187b84bd3..cf7d6c07f16 100644 --- a/packages/flutter/lib/src/material/search_anchor.dart +++ b/packages/flutter/lib/src/material/search_anchor.dart @@ -740,6 +740,8 @@ class _ViewContentState extends State<_ViewContent> { late Rect _viewRect; late final SearchController _controller; Iterable result = []; + String? searchValue; + Timer? _timer; @override void initState() { @@ -770,12 +772,23 @@ class _ViewContentState extends State<_ViewContent> { _viewRect = Offset.zero & _screenSize!; } } - unawaited(updateSuggestions()); + + if (searchValue != _controller.text) { + _timer?.cancel(); + _timer = Timer(Duration.zero, () async { + searchValue = _controller.text; + result = await widget.suggestionsBuilder(context, _controller); + _timer?.cancel(); + _timer = null; + }); + } } @override void dispose() { _controller.removeListener(updateSuggestions); + _timer?.cancel(); + _timer = null; super.dispose(); } @@ -793,11 +806,14 @@ class _ViewContentState extends State<_ViewContent> { } Future updateSuggestions() async { - final Iterable suggestions = await widget.suggestionsBuilder(context, _controller); - if (mounted) { - setState(() { - result = suggestions; - }); + if (searchValue != _controller.text) { + searchValue = _controller.text; + final Iterable suggestions = await widget.suggestionsBuilder(context, _controller); + if (mounted) { + setState(() { + result = suggestions; + }); + } } } diff --git a/packages/flutter/test/material/search_anchor_test.dart b/packages/flutter/test/material/search_anchor_test.dart index 4775e858dcd..76ed139616d 100644 --- a/packages/flutter/test/material/search_anchor_test.dart +++ b/packages/flutter/test/material/search_anchor_test.dart @@ -3131,6 +3131,104 @@ void main() { await tester.pump(); expect(find.widgetWithIcon(IconButton, Icons.close), findsNothing); }); + + // This is a regression test for https://github.com/flutter/flutter/issues/139880. + testWidgets('suggestionsBuilder with Future is not called twice on layout resize', (WidgetTester tester) async { + int suggestionsLoadingCount = 0; + + Future> createListData() async { + return List.generate(1000, (int index) { + return 'Hello World - $index'; + }); + } + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return [ + FutureBuilder>( + future: createListData(), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const LinearProgressIndicator(); + } + final List? result = snapshot.data; + if (result == null) { + return const LinearProgressIndicator(); + } + suggestionsLoadingCount++; + return SingleChildScrollView( + child: Column( + children: result.map((String text) { + return ListTile(title: Text(text)); + }).toList(), + ), + ); + }, + ), + ]; + }, + ), + ), + ), + )); + await tester.pump(); + await tester.tap(find.byIcon(Icons.search)); // Open search view. + await tester.pumpAndSettle(); + + // Simulate the keyboard opening resizing the view. + tester.view.viewInsets = const FakeViewPadding(bottom: 500.0); + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + + expect(suggestionsLoadingCount, 1); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/139880. + testWidgets('suggestionsBuilder is not called when the search value does not change', (WidgetTester tester) async { + int suggestionsBuilderCalledCount = 0; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Center( + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + suggestionsBuilderCalledCount++; + return []; + }, + ), + ), + ), + )); + await tester.pump(); + await tester.tap(find.byIcon(Icons.search)); // Open search view. + await tester.pumpAndSettle(); + + // Simulate the keyboard opening resizing the view. + tester.view.viewInsets = const FakeViewPadding(bottom: 500.0); + addTearDown(tester.view.reset); + // Show the keyboard. + await tester.showKeyboard(find.byType(TextField)); + await tester.pumpAndSettle(); + + expect(suggestionsBuilderCalledCount, 2); + + // Remove the viewInset, as if the keyboard were hidden. + tester.view.resetViewInsets(); + // Hide the keyboard. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(suggestionsBuilderCalledCount, 2); + }); } Future checkSearchBarDefaults(WidgetTester tester, ColorScheme colorScheme, Material material) async {