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(