Skip to content

Commit 7b67aa5

Browse files
authored
make suggestionsBuilder in SearchAnchor asyncable (#127019)
1 parent a19b343 commit 7b67aa5

6 files changed

Lines changed: 454 additions & 7 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [SearchAnchor] that shows how to fetch the suggestions
8+
/// from a remote API.
9+
10+
const Duration fakeAPIDuration = Duration(seconds: 1);
11+
12+
void main() => runApp(const SearchAnchorAsyncExampleApp());
13+
14+
class SearchAnchorAsyncExampleApp extends StatelessWidget {
15+
const SearchAnchorAsyncExampleApp({super.key});
16+
17+
@override
18+
Widget build(BuildContext context) {
19+
return MaterialApp(
20+
home: Scaffold(
21+
appBar: AppBar(
22+
title: const Text('SearchAnchor - async'),
23+
),
24+
body: const Center(
25+
child: _AsyncSearchAnchor(),
26+
),
27+
),
28+
);
29+
}
30+
}
31+
32+
class _AsyncSearchAnchor extends StatefulWidget {
33+
const _AsyncSearchAnchor();
34+
35+
@override
36+
State<_AsyncSearchAnchor > createState() => _AsyncSearchAnchorState();
37+
}
38+
39+
class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor > {
40+
// The query currently being searched for. If null, there is no pending
41+
// request.
42+
String? _searchingWithQuery;
43+
44+
// The most recent options received from the API.
45+
late Iterable<Widget> _lastOptions = <Widget>[];
46+
47+
@override
48+
Widget build(BuildContext context) {
49+
return SearchAnchor(
50+
builder: (BuildContext context, SearchController controller) {
51+
return IconButton(
52+
icon: const Icon(Icons.search),
53+
onPressed: () {
54+
controller.openView();
55+
},
56+
);
57+
},
58+
suggestionsBuilder: (BuildContext context, SearchController controller) async {
59+
_searchingWithQuery = controller.text;
60+
final List<String> options = (await _FakeAPI.search(_searchingWithQuery!)).toList();
61+
62+
// If another search happened after this one, throw away these options.
63+
// Use the previous options intead and wait for the newer request to
64+
// finish.
65+
if (_searchingWithQuery != controller.text) {
66+
return _lastOptions;
67+
}
68+
69+
_lastOptions = List<ListTile>.generate(options.length, (int index) {
70+
final String item = options[index];
71+
return ListTile(
72+
title: Text(item),
73+
);
74+
});
75+
76+
return _lastOptions;
77+
});
78+
}
79+
}
80+
81+
// Mimics a remote API.
82+
class _FakeAPI {
83+
static const List<String> _kOptions = <String>[
84+
'aardvark',
85+
'bobcat',
86+
'chameleon',
87+
];
88+
89+
// Searches the options, but injects a fake "network" delay.
90+
static Future<Iterable<String>> search(String query) async {
91+
await Future<void>.delayed(fakeAPIDuration); // Fake 1 second delay.
92+
if (query == '') {
93+
return const Iterable<String>.empty();
94+
}
95+
return _kOptions.where((String option) {
96+
return option.contains(query.toLowerCase());
97+
});
98+
}
99+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'package:flutter/material.dart';
8+
9+
/// Flutter code sample for [SearchAnchor] that demonstrates fetching the
10+
/// suggestions asynchronously and debouncing the network calls.
11+
12+
const Duration fakeAPIDuration = Duration(seconds: 1);
13+
const Duration debounceDuration = Duration(milliseconds: 500);
14+
15+
void main() => runApp(const SearchAnchorAsyncExampleApp());
16+
17+
class SearchAnchorAsyncExampleApp extends StatelessWidget {
18+
const SearchAnchorAsyncExampleApp({super.key});
19+
20+
@override
21+
Widget build(BuildContext context) {
22+
return MaterialApp(
23+
home: Scaffold(
24+
appBar: AppBar(
25+
title: const Text('SearchAnchor - async and debouncing'),
26+
),
27+
body: const Center(
28+
child: _AsyncSearchAnchor(),
29+
),
30+
),
31+
);
32+
}
33+
}
34+
35+
class _AsyncSearchAnchor extends StatefulWidget {
36+
const _AsyncSearchAnchor();
37+
38+
@override
39+
State<_AsyncSearchAnchor > createState() => _AsyncSearchAnchorState();
40+
}
41+
42+
class _AsyncSearchAnchorState extends State<_AsyncSearchAnchor > {
43+
// The query currently being searched for. If null, there is no pending
44+
// request.
45+
String? _currentQuery;
46+
47+
// The most recent suggestions received from the API.
48+
late Iterable<Widget> _lastOptions = <Widget>[];
49+
50+
late final _Debounceable<Iterable<String>?, String> _debouncedSearch;
51+
52+
// Calls the "remote" API to search with the given query. Returns null when
53+
// the call has been made obsolete.
54+
Future<Iterable<String>?> _search(String query) async {
55+
_currentQuery = query;
56+
57+
// In a real application, there should be some error handling here.
58+
final Iterable<String> options = await _FakeAPI.search(_currentQuery!);
59+
60+
// If another search happened after this one, throw away these options.
61+
if (_currentQuery != query) {
62+
return null;
63+
}
64+
_currentQuery = null;
65+
66+
return options;
67+
}
68+
69+
@override
70+
void initState() {
71+
super.initState();
72+
_debouncedSearch = _debounce<Iterable<String>?, String>(_search);
73+
}
74+
75+
@override
76+
Widget build(BuildContext context) {
77+
return SearchAnchor(
78+
builder: (BuildContext context, SearchController controller) {
79+
return IconButton(
80+
icon: const Icon(Icons.search),
81+
onPressed: () {
82+
controller.openView();
83+
},
84+
);
85+
},
86+
suggestionsBuilder: (BuildContext context, SearchController controller) async {
87+
final List<String>? options = (await _debouncedSearch(controller.text))?.toList();
88+
if (options == null) {
89+
return _lastOptions;
90+
}
91+
_lastOptions = List<ListTile>.generate(options.length, (int index) {
92+
final String item = options[index];
93+
return ListTile(
94+
title: Text(item),
95+
onTap: () {
96+
debugPrint('You just selected $item');
97+
},
98+
);
99+
});
100+
101+
return _lastOptions;
102+
},
103+
);
104+
}
105+
}
106+
107+
// Mimics a remote API.
108+
class _FakeAPI {
109+
static const List<String> _kOptions = <String>[
110+
'aardvark',
111+
'bobcat',
112+
'chameleon',
113+
];
114+
115+
// Searches the options, but injects a fake "network" delay.
116+
static Future<Iterable<String>> search(String query) async {
117+
await Future<void>.delayed(fakeAPIDuration); // Fake 1 second delay.
118+
if (query == '') {
119+
return const Iterable<String>.empty();
120+
}
121+
return _kOptions.where((String option) {
122+
return option.contains(query.toLowerCase());
123+
});
124+
}
125+
}
126+
127+
typedef _Debounceable<S, T> = Future<S?> Function(T parameter);
128+
129+
/// Returns a new function that is a debounced version of the given function.
130+
///
131+
/// This means that the original function will be called only after no calls
132+
/// have been made for the given Duration.
133+
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {
134+
_DebounceTimer? debounceTimer;
135+
136+
return (T parameter) async {
137+
if (debounceTimer != null && !debounceTimer!.isCompleted) {
138+
debounceTimer!.cancel();
139+
}
140+
debounceTimer = _DebounceTimer();
141+
try {
142+
await debounceTimer!.future;
143+
} catch (error) {
144+
if (error is _CancelException) {
145+
return null;
146+
}
147+
rethrow;
148+
}
149+
return function(parameter);
150+
};
151+
}
152+
153+
// A wrapper around Timer used for debouncing.
154+
class _DebounceTimer {
155+
_DebounceTimer() {
156+
_timer = Timer(debounceDuration, _onComplete);
157+
}
158+
159+
late final Timer _timer;
160+
final Completer<void> _completer = Completer<void>();
161+
162+
void _onComplete() {
163+
_completer.complete();
164+
}
165+
166+
Future<void> get future => _completer.future;
167+
168+
bool get isCompleted => _completer.isCompleted;
169+
170+
void cancel() {
171+
_timer.cancel();
172+
_completer.completeError(const _CancelException());
173+
}
174+
}
175+
176+
// An exception indicating that the timer was canceled.
177+
class _CancelException implements Exception {
178+
const _CancelException();
179+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/material/search_anchor/search_anchor.3.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('can search and find options after waiting for fake network delay', (WidgetTester tester) async {
11+
await tester.pumpWidget(const example.SearchAnchorAsyncExampleApp());
12+
13+
await tester.tap(find.byIcon(Icons.search));
14+
await tester.pumpAndSettle();
15+
16+
expect(find.widgetWithText(ListTile, 'aardvark'), findsNothing);
17+
expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
18+
expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
19+
20+
await tester.enterText(find.byType(SearchBar), 'a');
21+
await tester.pump(example.fakeAPIDuration);
22+
23+
expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget);
24+
expect(find.widgetWithText(ListTile, 'bobcat'), findsOneWidget);
25+
expect(find.widgetWithText(ListTile, 'chameleon'), findsOneWidget);
26+
27+
await tester.enterText(find.byType(SearchBar), 'aa');
28+
await tester.pump(example.fakeAPIDuration);
29+
30+
expect(find.widgetWithText(ListTile, 'aardvark'), findsOneWidget);
31+
expect(find.widgetWithText(ListTile, 'bobcat'), findsNothing);
32+
expect(find.widgetWithText(ListTile, 'chameleon'), findsNothing);
33+
});
34+
}

0 commit comments

Comments
 (0)