From 7b67aa587ac613db4b7ff6cb77457117db1b7486 Mon Sep 17 00:00:00 2001
From: Sun Jiao
Date: Thu, 25 May 2023 04:12:47 +0800
Subject: [PATCH] make `suggestionsBuilder` in `SearchAnchor` asyncable
(#127019)
---
.../search_anchor/search_anchor.3.dart | 99 ++++++++++
.../search_anchor/search_anchor.4.dart | 179 ++++++++++++++++++
.../search_anchor/search_anchor.3_test.dart | 34 ++++
.../search_anchor/search_anchor.4_test.dart | 86 +++++++++
.../lib/src/material/search_anchor.dart | 18 +-
.../test/material/search_anchor_test.dart | 45 +++++
6 files changed, 454 insertions(+), 7 deletions(-)
create mode 100644 examples/api/lib/material/search_anchor/search_anchor.3.dart
create mode 100644 examples/api/lib/material/search_anchor/search_anchor.4.dart
create mode 100644 examples/api/test/material/search_anchor/search_anchor.3_test.dart
create mode 100644 examples/api/test/material/search_anchor/search_anchor.4_test.dart
diff --git a/examples/api/lib/material/search_anchor/search_anchor.3.dart b/examples/api/lib/material/search_anchor/search_anchor.3.dart
new file mode 100644
index 0000000000..80694e04d1
--- /dev/null
+++ b/examples/api/lib/material/search_anchor/search_anchor.3.dart
@@ -0,0 +1,99 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+
+/// Flutter code sample for [SearchAnchor] that shows how to fetch the suggestions
+/// from a remote API.
+
+const Duration fakeAPIDuration = Duration(seconds: 1);
+
+void main() => runApp(const SearchAnchorAsyncExampleApp());
+
+class SearchAnchorAsyncExampleApp extends StatelessWidget {
+ const SearchAnchorAsyncExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ home: Scaffold(
+ appBar: AppBar(
+ title: const Text('SearchAnchor - async'),
+ ),
+ body: const Center(
+ child: _AsyncSearchAnchor(),
+ ),
+ ),
+ );
+ }
+}
+
+class _AsyncSearchAnchor extends StatefulWidget {
+ const _AsyncSearchAnchor();
+
+ @override
+ State<_AsyncSearchAnchor > createState() => _AsyncSearchAnchorState();
+}
+
+class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor > {
+ // The query currently being searched for. If null, there is no pending
+ // request.
+ String? _searchingWithQuery;
+
+ // The most recent options received from the API.
+ late Iterable _lastOptions = [];
+
+ @override
+ Widget build(BuildContext context) {
+ return SearchAnchor(
+ builder: (BuildContext context, SearchController controller) {
+ return IconButton(
+ icon: const Icon(Icons.search),
+ onPressed: () {
+ controller.openView();
+ },
+ );
+ },
+ suggestionsBuilder: (BuildContext context, SearchController controller) async {
+ _searchingWithQuery = controller.text;
+ final List options = (await _FakeAPI.search(_searchingWithQuery!)).toList();
+
+ // If another search happened after this one, throw away these options.
+ // Use the previous options intead and wait for the newer request to
+ // finish.
+ if (_searchingWithQuery != controller.text) {
+ return _lastOptions;
+ }
+
+ _lastOptions = List.generate(options.length, (int index) {
+ final String item = options[index];
+ return ListTile(
+ title: Text(item),
+ );
+ });
+
+ return _lastOptions;
+ });
+ }
+}
+
+// Mimics a remote API.
+class _FakeAPI {
+ static const List _kOptions = [
+ 'aardvark',
+ 'bobcat',
+ 'chameleon',
+ ];
+
+ // Searches the options, but injects a fake "network" delay.
+ static Future> search(String query) async {
+ await Future.delayed(fakeAPIDuration); // Fake 1 second delay.
+ if (query == '') {
+ return const Iterable.empty();
+ }
+ return _kOptions.where((String option) {
+ return option.contains(query.toLowerCase());
+ });
+ }
+}
diff --git a/examples/api/lib/material/search_anchor/search_anchor.4.dart b/examples/api/lib/material/search_anchor/search_anchor.4.dart
new file mode 100644
index 0000000000..8417ce6113
--- /dev/null
+++ b/examples/api/lib/material/search_anchor/search_anchor.4.dart
@@ -0,0 +1,179 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+/// Flutter code sample for [SearchAnchor] that demonstrates fetching the
+/// suggestions asynchronously and debouncing the network calls.
+
+const Duration fakeAPIDuration = Duration(seconds: 1);
+const Duration debounceDuration = Duration(milliseconds: 500);
+
+void main() => runApp(const SearchAnchorAsyncExampleApp());
+
+class SearchAnchorAsyncExampleApp extends StatelessWidget {
+ const SearchAnchorAsyncExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ home: Scaffold(
+ appBar: AppBar(
+ title: const Text('SearchAnchor - async and debouncing'),
+ ),
+ body: const Center(
+ child: _AsyncSearchAnchor(),
+ ),
+ ),
+ );
+ }
+}
+
+class _AsyncSearchAnchor extends StatefulWidget {
+ const _AsyncSearchAnchor();
+
+ @override
+ State<_AsyncSearchAnchor > createState() => _AsyncSearchAnchorState();
+}
+
+class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor > {
+ // The query currently being searched for. If null, there is no pending
+ // request.
+ String? _currentQuery;
+
+ // The most recent suggestions received from the API.
+ late Iterable _lastOptions = [];
+
+ late final _Debounceable?, String> _debouncedSearch;
+
+ // Calls the "remote" API to search with the given query. Returns null when
+ // the call has been made obsolete.
+ Future?> _search(String query) async {
+ _currentQuery = query;
+
+ // In a real application, there should be some error handling here.
+ final Iterable options = await _FakeAPI.search(_currentQuery!);
+
+ // If another search happened after this one, throw away these options.
+ if (_currentQuery != query) {
+ return null;
+ }
+ _currentQuery = null;
+
+ return options;
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _debouncedSearch = _debounce?, String>(_search);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SearchAnchor(
+ builder: (BuildContext context, SearchController controller) {
+ return IconButton(
+ icon: const Icon(Icons.search),
+ onPressed: () {
+ controller.openView();
+ },
+ );
+ },
+ suggestionsBuilder: (BuildContext context, SearchController controller) async {
+ final List? options = (await _debouncedSearch(controller.text))?.toList();
+ if (options == null) {
+ return _lastOptions;
+ }
+ _lastOptions = List.generate(options.length, (int index) {
+ final String item = options[index];
+ return ListTile(
+ title: Text(item),
+ onTap: () {
+ debugPrint('You just selected $item');
+ },
+ );
+ });
+
+ return _lastOptions;
+ },
+ );
+ }
+}
+
+// Mimics a remote API.
+class _FakeAPI {
+ static const List _kOptions = [
+ 'aardvark',
+ 'bobcat',
+ 'chameleon',
+ ];
+
+ // Searches the options, but injects a fake "network" delay.
+ static Future> search(String query) async {
+ await Future.delayed(fakeAPIDuration); // Fake 1 second delay.
+ if (query == '') {
+ return const Iterable.empty();
+ }
+ return _kOptions.where((String option) {
+ return option.contains(query.toLowerCase());
+ });
+ }
+}
+
+typedef _Debounceable = Future Function(T parameter);
+
+/// Returns a new function that is a debounced version of the given function.
+///
+/// This means that the original function will be called only after no calls
+/// have been made for the given Duration.
+_Debounceable _debounce(_Debounceable function) {
+ _DebounceTimer? debounceTimer;
+
+ return (T parameter) async {
+ if (debounceTimer != null && !debounceTimer!.isCompleted) {
+ debounceTimer!.cancel();
+ }
+ debounceTimer = _DebounceTimer();
+ try {
+ await debounceTimer!.future;
+ } catch (error) {
+ if (error is _CancelException) {
+ return null;
+ }
+ rethrow;
+ }
+ return function(parameter);
+ };
+}
+
+// A wrapper around Timer used for debouncing.
+class _DebounceTimer {
+ _DebounceTimer() {
+ _timer = Timer(debounceDuration, _onComplete);
+ }
+
+ late final Timer _timer;
+ final Completer _completer = Completer();
+
+ void _onComplete() {
+ _completer.complete();
+ }
+
+ Future get future => _completer.future;
+
+ bool get isCompleted => _completer.isCompleted;
+
+ void cancel() {
+ _timer.cancel();
+ _completer.completeError(const _CancelException());
+ }
+}
+
+// An exception indicating that the timer was canceled.
+class _CancelException implements Exception {
+ const _CancelException();
+}
diff --git a/examples/api/test/material/search_anchor/search_anchor.3_test.dart b/examples/api/test/material/search_anchor/search_anchor.3_test.dart
new file mode 100644
index 0000000000..484c5cdfe0
--- /dev/null
+++ b/examples/api/test/material/search_anchor/search_anchor.3_test.dart
@@ -0,0 +1,34 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/search_anchor/search_anchor.3.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('can search and find options after waiting for fake network delay', (WidgetTester tester) async {
+ await tester.pumpWidget(const example.SearchAnchorAsyncExampleApp());
+
+ await tester.tap(find.byIcon(Icons.search));
+ await tester.pumpAndSettle();
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+
+ await tester.enterText(find.byType(SearchBar), 'a');
+ await tester.pump(example.fakeAPIDuration);
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsOneWidget);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsOneWidget);
+
+ await tester.enterText(find.byType(SearchBar), 'aa');
+ await tester.pump(example.fakeAPIDuration);
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+ });
+}
diff --git a/examples/api/test/material/search_anchor/search_anchor.4_test.dart b/examples/api/test/material/search_anchor/search_anchor.4_test.dart
new file mode 100644
index 0000000000..9074fb95b3
--- /dev/null
+++ b/examples/api/test/material/search_anchor/search_anchor.4_test.dart
@@ -0,0 +1,86 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/search_anchor/search_anchor.4.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('can search and find options after waiting for fake network delay and debounce delay', (WidgetTester tester) async {
+ await tester.pumpWidget(const example.SearchAnchorAsyncExampleApp());
+
+ await tester.tap(find.byIcon(Icons.search));
+ await tester.pumpAndSettle();
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+
+ await tester.enterText(find.byType(SearchBar), 'a');
+ await tester.pump(example.fakeAPIDuration);
+
+ // No results yet, need to also wait for the debounce duration.
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+
+ await tester.pump(example.debounceDuration);
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsOneWidget);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsOneWidget);
+
+ await tester.enterText(find.byType(SearchBar), 'aa');
+ await tester.pump(example.debounceDuration + example.fakeAPIDuration);
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+ });
+
+ testWidgets('debounce is reset each time a character is entered', (WidgetTester tester) async {
+ await tester.pumpWidget(const example.SearchAnchorAsyncExampleApp());
+
+ await tester.tap(find.byIcon(Icons.search));
+ await tester.pumpAndSettle();
+
+ await tester.enterText(find.byType(SearchBar), 'c');
+ await tester.pump(example.debounceDuration - const Duration(milliseconds: 100));
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+
+ await tester.enterText(find.byType(SearchBar), 'ch');
+ await tester.pump(example.debounceDuration - const Duration(milliseconds: 100));
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+
+ await tester.enterText(find.byType(SearchBar), 'cha');
+ await tester.pump(example.debounceDuration - const Duration(milliseconds: 100));
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+
+ await tester.enterText(find.byType(SearchBar), 'cham');
+ await tester.pump(example.debounceDuration - const Duration(milliseconds: 100));
+
+ // Despite the total elapsed time being greater than debounceDuration +
+ // fakeAPIDuration, the search has not yet completed, because the debounce
+ // was reset each time text input happened.
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
+
+ await tester.enterText(find.byType(SearchBar), 'chame');
+ await tester.pump(example.debounceDuration + example.fakeAPIDuration);
+
+ expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
+ expect(find.widgetWithText(ListTile, 'chameleon'), findsOneWidget);
+ });
+}
diff --git a/packages/flutter/lib/src/material/search_anchor.dart b/packages/flutter/lib/src/material/search_anchor.dart
index 74ace69e66..5541de9655 100644
--- a/packages/flutter/lib/src/material/search_anchor.dart
+++ b/packages/flutter/lib/src/material/search_anchor.dart
@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
@@ -46,7 +47,7 @@ typedef SearchAnchorChildBuilder = Widget Function(BuildContext context, SearchC
///
/// The `controller` callback provided to [SearchAnchor.suggestionsBuilder] can be used
/// to close the search view and control the editable field on the view.
-typedef SuggestionsBuilder = Iterable Function(BuildContext context, SearchController controller);
+typedef SuggestionsBuilder = FutureOr> Function(BuildContext context, SearchController controller);
/// Signature for a function that creates a [Widget] to layout the suggestion list.
///
@@ -648,7 +649,7 @@ class _ViewContentState extends State<_ViewContent> {
Size? _screenSize;
late Rect _viewRect;
late final SearchController _controller;
- late Iterable result;
+ Iterable result = [];
final FocusNode _focusNode = FocusNode();
@override
@@ -674,7 +675,6 @@ class _ViewContentState extends State<_ViewContent> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
- result = widget.suggestionsBuilder(context, _controller);
final Size updatedScreenSize = MediaQuery.of(context).size;
if (_screenSize != updatedScreenSize) {
@@ -683,6 +683,7 @@ class _ViewContentState extends State<_ViewContent> {
_viewRect = Offset.zero & _screenSize!;
}
}
+ unawaited(updateSuggestions());
}
Widget viewBuilder(Iterable suggestions) {
@@ -698,10 +699,13 @@ class _ViewContentState extends State<_ViewContent> {
return widget.viewBuilder!(suggestions);
}
- void updateSuggestions() {
- setState(() {
- result = widget.suggestionsBuilder(context, _controller);
- });
+ Future updateSuggestions() async {
+ final Iterable suggestions = await widget.suggestionsBuilder(context, _controller);
+ if (mounted) {
+ setState(() {
+ result = suggestions;
+ });
+ }
}
@override
diff --git a/packages/flutter/test/material/search_anchor_test.dart b/packages/flutter/test/material/search_anchor_test.dart
index b6ff5b02ce..11bd7dc47f 100644
--- a/packages/flutter/test/material/search_anchor_test.dart
+++ b/packages/flutter/test/material/search_anchor_test.dart
@@ -1362,6 +1362,51 @@ void main() {
expect(controller.value.text, suggestion);
});
+ testWidgets('SearchAnchor suggestionsBuilder property could be async', (WidgetTester tester) async {
+ final SearchController controller = SearchController();
+ const String suggestion = 'suggestion text';
+
+ await tester.pumpWidget(MaterialApp(
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return Material(
+ child: Align(
+ alignment: Alignment.topCenter,
+ child: SearchAnchor(
+ searchController: controller,
+ builder: (BuildContext context, SearchController controller) {
+ return const Icon(Icons.search);
+ },
+ suggestionsBuilder: (BuildContext context, SearchController controller) async {
+ return [
+ ListTile(
+ title: const Text(suggestion),
+ onTap: () {
+ setState(() {
+ controller.closeView(suggestion);
+ });
+ },
+ ),
+ ];
+ },
+ ),
+ ),
+ );
+ },
+ ),
+ ));
+ await tester.tap(find.byIcon(Icons.search));
+ await tester.pumpAndSettle();
+
+ final Finder text = find.text(suggestion);
+ expect(text, findsOneWidget);
+ await tester.tap(text);
+ await tester.pumpAndSettle();
+
+ expect(controller.isOpen, false);
+ expect(controller.value.text, suggestion);
+ });
+
testWidgets('SearchAnchor.bar has a default search bar as the anchor', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Material(