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/// @docImport 'action_buttons.dart';
6/// @docImport 'animated_icons.dart';
7/// @docImport 'icon_button.dart';
8/// @docImport 'list_tile.dart';
9library;
10
11import 'dart:ui';
12
13import 'package:flutter/services.dart';
14import 'package:flutter/widgets.dart';
15
16import 'app_bar.dart';
17import 'app_bar_theme.dart';
18import 'color_scheme.dart';
19import 'colors.dart';
20import 'debug.dart';
21import 'input_border.dart';
22import 'input_decorator.dart';
23import 'material_localizations.dart';
24import 'scaffold.dart';
25import 'text_field.dart';
26import 'theme.dart';
27
28/// Shows a full screen search page and returns the search result selected by
29/// the user when the page is closed.
30///
31/// The search page consists of an app bar with a search field and a body which
32/// can either show suggested search queries or the search results.
33///
34/// The appearance of the search page is determined by the provided
35/// `delegate`. The initial query string is given by `query`, which defaults
36/// to the empty string. When `query` is set to null, `delegate.query` will
37/// be used as the initial query.
38///
39/// This method returns the selected search result, which can be set in the
40/// [SearchDelegate.close] call. If the search page is closed with the system
41/// back button, it returns null.
42///
43/// A given [SearchDelegate] can only be associated with one active [showSearch]
44/// call. Call [SearchDelegate.close] before re-using the same delegate instance
45/// for another [showSearch] call.
46///
47/// The `useRootNavigator` argument is used to determine whether to push the
48/// search page to the [Navigator] furthest from or nearest to the given
49/// `context`. By default, `useRootNavigator` is `false` and the search page
50/// route created by this method is pushed to the nearest navigator to the
51/// given `context`. It can not be `null`.
52///
53/// The `maintainState` argument is used to determine if the route should remain
54/// in memory when it is inactive (see [ModalRoute.maintainState] for more details].
55/// By default, `maintainState` is `false`.
56///
57/// The transition to the search page triggered by this method looks best if the
58/// screen triggering the transition contains an [AppBar] at the top and the
59/// transition is called from an [IconButton] that's part of [AppBar.actions].
60/// The animation provided by [SearchDelegate.transitionAnimation] can be used
61/// to trigger additional animations in the underlying page while the search
62/// page fades in or out. This is commonly used to animate an [AnimatedIcon] in
63/// the [AppBar.leading] position e.g. from the hamburger menu to the back arrow
64/// used to exit the search page.
65///
66/// ## Handling emojis and other complex characters
67/// {@macro flutter.widgets.EditableText.onChanged}
68///
69/// See also:
70///
71/// * [SearchDelegate] to define the content of the search page.
72Future<T?> showSearch<T>({
73 required BuildContext context,
74 required SearchDelegate<T> delegate,
75 String? query = '',
76 bool useRootNavigator = false,
77 bool maintainState = false,
78}) {
79 delegate.query = query ?? delegate.query;
80 delegate._currentBody = _SearchBody.suggestions;
81 return Navigator.of(
82 context,
83 rootNavigator: useRootNavigator,
84 ).push(_SearchPageRoute<T>(delegate: delegate, maintainState: maintainState));
85}
86
87/// Delegate for [showSearch] to define the content of the search page.
88///
89/// The search page always shows an [AppBar] at the top where users can
90/// enter their search queries. The buttons shown before and after the search
91/// query text field can be customized via [SearchDelegate.buildLeading]
92/// and [SearchDelegate.buildActions]. Additionally, a widget can be placed
93/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom].
94///
95/// The body below the [AppBar] can either show suggested queries (returned by
96/// [SearchDelegate.buildSuggestions]) or - once the user submits a search - the
97/// results of the search as returned by [SearchDelegate.buildResults].
98///
99/// [SearchDelegate.query] always contains the current query entered by the user
100/// and should be used to build the suggestions and results.
101///
102/// The results can be brought on screen by calling [SearchDelegate.showResults]
103/// and you can go back to showing the suggestions by calling
104/// [SearchDelegate.showSuggestions].
105///
106/// Once the user has selected a search result, [SearchDelegate.close] should be
107/// called to remove the search page from the top of the navigation stack and
108/// to notify the caller of [showSearch] about the selected search result.
109///
110/// A given [SearchDelegate] can only be associated with one active [showSearch]
111/// call. Call [SearchDelegate.close] before re-using the same delegate instance
112/// for another [showSearch] call.
113///
114/// ## Handling emojis and other complex characters
115/// {@macro flutter.widgets.EditableText.onChanged}
116abstract class SearchDelegate<T> {
117 /// Constructor to be called by subclasses which may specify
118 /// [searchFieldLabel], either [searchFieldStyle] or [searchFieldDecorationTheme],
119 /// [keyboardType] and/or [textInputAction]. Only one of [searchFieldLabel]
120 /// and [searchFieldDecorationTheme] may be non-null.
121 ///
122 /// {@tool snippet}
123 /// ```dart
124 /// class CustomSearchHintDelegate extends SearchDelegate<String> {
125 /// CustomSearchHintDelegate({
126 /// required String hintText,
127 /// }) : super(
128 /// searchFieldLabel: hintText,
129 /// keyboardType: TextInputType.text,
130 /// textInputAction: TextInputAction.search,
131 /// );
132 ///
133 /// @override
134 /// Widget buildLeading(BuildContext context) => const Text('leading');
135 ///
136 /// @override
137 /// PreferredSizeWidget buildBottom(BuildContext context) {
138 /// return const PreferredSize(
139 /// preferredSize: Size.fromHeight(56.0),
140 /// child: Text('bottom'));
141 /// }
142 ///
143 /// @override
144 /// Widget buildSuggestions(BuildContext context) => const Text('suggestions');
145 ///
146 /// @override
147 /// Widget buildResults(BuildContext context) => const Text('results');
148 ///
149 /// @override
150 /// List<Widget> buildActions(BuildContext context) => <Widget>[];
151 /// }
152 /// ```
153 /// {@end-tool}
154 SearchDelegate({
155 this.searchFieldLabel,
156 this.searchFieldStyle,
157 this.searchFieldDecorationTheme,
158 this.keyboardType,
159 this.textInputAction = TextInputAction.search,
160 this.autocorrect = true,
161 this.enableSuggestions = true,
162 }) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null);
163
164 /// Suggestions shown in the body of the search page while the user types a
165 /// query into the search field.
166 ///
167 /// The delegate method is called whenever the content of [query] changes.
168 /// The suggestions should be based on the current [query] string. If the query
169 /// string is empty, it is good practice to show suggested queries based on
170 /// past queries or the current context.
171 ///
172 /// Usually, this method will return a [ListView] with one [ListTile] per
173 /// suggestion. When [ListTile.onTap] is called, [query] should be updated
174 /// with the corresponding suggestion and the results page should be shown
175 /// by calling [showResults].
176 Widget buildSuggestions(BuildContext context);
177
178 /// The results shown after the user submits a search from the search page.
179 ///
180 /// The current value of [query] can be used to determine what the user
181 /// searched for.
182 ///
183 /// This method might be applied more than once to the same query.
184 /// If your [buildResults] method is computationally expensive, you may want
185 /// to cache the search results for one or more queries.
186 ///
187 /// Typically, this method returns a [ListView] with the search results.
188 /// When the user taps on a particular search result, [close] should be called
189 /// with the selected result as argument. This will close the search page and
190 /// communicate the result back to the initial caller of [showSearch].
191 Widget buildResults(BuildContext context);
192
193 /// A widget to display before the current query in the [AppBar].
194 ///
195 /// Typically an [IconButton] configured with a [BackButtonIcon] that exits
196 /// the search with [close]. One can also use an [AnimatedIcon] driven by
197 /// [transitionAnimation], which animates from e.g. a hamburger menu to the
198 /// back button as the search overlay fades in.
199 ///
200 /// Returns null if no widget should be shown.
201 ///
202 /// See also:
203 ///
204 /// * [AppBar.leading], the intended use for the return value of this method.
205 Widget? buildLeading(BuildContext context);
206
207 /// {@macro flutter.material.appbar.automaticallyImplyLeading}
208 bool? automaticallyImplyLeading;
209
210 /// {@macro flutter.material.appbar.leadingWidth}
211 double? leadingWidth;
212
213 /// Widgets to display after the search query in the [AppBar].
214 ///
215 /// If the [query] is not empty, this should typically contain a button to
216 /// clear the query and show the suggestions again (via [showSuggestions]) if
217 /// the results are currently shown.
218 ///
219 /// Returns null if no widget should be shown.
220 ///
221 /// See also:
222 ///
223 /// * [AppBar.actions], the intended use for the return value of this method.
224 List<Widget>? buildActions(BuildContext context);
225
226 /// Widget to display across the bottom of the [AppBar].
227 ///
228 /// Returns null by default, i.e. a bottom widget is not included.
229 ///
230 /// See also:
231 ///
232 /// * [AppBar.bottom], the intended use for the return value of this method.
233 ///
234 PreferredSizeWidget? buildBottom(BuildContext context) => null;
235
236 /// Widget to display a flexible space in the [AppBar].
237 ///
238 /// Returns null by default, i.e. a flexible space widget is not included.
239 ///
240 /// See also:
241 ///
242 /// * [AppBar.flexibleSpace], the intended use for the return value of this method.
243 Widget? buildFlexibleSpace(BuildContext context) => null;
244
245 /// The theme used to configure the search page.
246 ///
247 /// The returned [ThemeData] will be used to wrap the entire search page,
248 /// so it can be used to configure any of its components with the appropriate
249 /// theme properties.
250 ///
251 /// Unless overridden, the default theme will configure the AppBar containing
252 /// the search input text field with a white background and black text on light
253 /// themes. For dark themes the default is a dark grey background with light
254 /// color text.
255 ///
256 /// See also:
257 ///
258 /// * [AppBarTheme], which configures the AppBar's appearance.
259 /// * [InputDecorationTheme], which configures the appearance of the search
260 /// text field.
261 ThemeData appBarTheme(BuildContext context) {
262 final ThemeData theme = Theme.of(context);
263 final ColorScheme colorScheme = theme.colorScheme;
264 return theme.copyWith(
265 appBarTheme: AppBarThemeData(
266 systemOverlayStyle: colorScheme.brightness == Brightness.dark
267 ? SystemUiOverlayStyle.light
268 : SystemUiOverlayStyle.dark,
269 backgroundColor: colorScheme.brightness == Brightness.dark
270 ? Colors.grey[900]
271 : Colors.white,
272 iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),
273 titleTextStyle: theme.textTheme.titleLarge,
274 toolbarTextStyle: theme.textTheme.bodyMedium,
275 ),
276 inputDecorationTheme:
277 searchFieldDecorationTheme ??
278 InputDecorationTheme(
279 hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle,
280 border: InputBorder.none,
281 ),
282 );
283 }
284
285 /// The current query string shown in the [AppBar].
286 ///
287 /// The user manipulates this string via the keyboard.
288 ///
289 /// If the user taps on a suggestion provided by [buildSuggestions] this
290 /// string should be updated to that suggestion via the setter.
291 String get query => _queryTextController.text;
292
293 /// Changes the current query string.
294 ///
295 /// Setting the query string programmatically moves the cursor to the end of the text field.
296 set query(String value) {
297 _queryTextController.value = TextEditingValue(
298 text: value,
299 selection: TextSelection.collapsed(offset: value.length),
300 );
301 }
302
303 /// Transition from the suggestions returned by [buildSuggestions] to the
304 /// [query] results returned by [buildResults].
305 ///
306 /// If the user taps on a suggestion provided by [buildSuggestions] the
307 /// screen should typically transition to the page showing the search
308 /// results for the suggested query. This transition can be triggered
309 /// by calling this method.
310 ///
311 /// See also:
312 ///
313 /// * [showSuggestions] to show the search suggestions again.
314 void showResults(BuildContext context) {
315 _focusNode?.unfocus();
316 _currentBody = _SearchBody.results;
317 }
318
319 /// Transition from showing the results returned by [buildResults] to showing
320 /// the suggestions returned by [buildSuggestions].
321 ///
322 /// Calling this method will also put the input focus back into the search
323 /// field of the [AppBar].
324 ///
325 /// If the results are currently shown this method can be used to go back
326 /// to showing the search suggestions.
327 ///
328 /// See also:
329 ///
330 /// * [showResults] to show the search results.
331 void showSuggestions(BuildContext context) {
332 assert(_focusNode != null, '_focusNode must be set by route before showSuggestions is called.');
333 _focusNode!.requestFocus();
334 _currentBody = _SearchBody.suggestions;
335 }
336
337 /// Closes the search page and returns to the underlying route.
338 ///
339 /// The value provided for `result` is used as the return value of the call
340 /// to [showSearch] that launched the search initially.
341 void close(BuildContext context, T result) {
342 _currentBody = null;
343 _focusNode?.unfocus();
344 Navigator.of(context)
345 ..popUntil((Route<dynamic> route) => route == _route)
346 ..pop(result);
347 }
348
349 /// Closes the search page and returns to the underlying route whitout result.
350 void _pop(BuildContext context) {
351 _currentBody = null;
352 _focusNode?.unfocus();
353 Navigator.of(context)
354 ..popUntil((Route<dynamic> route) => route == _route)
355 ..pop();
356 }
357
358 /// The hint text that is shown in the search field when it is empty.
359 ///
360 /// If this value is set to null, the value of
361 /// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead.
362 final String? searchFieldLabel;
363
364 /// The style of the [searchFieldLabel].
365 ///
366 /// If this value is set to null, the value of the ambient [Theme]'s
367 /// [InputDecorationTheme.hintStyle] will be used instead.
368 ///
369 /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
370 /// be non-null.
371 final TextStyle? searchFieldStyle;
372
373 /// The [InputDecorationTheme] used to configure the search field's visuals.
374 ///
375 /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
376 /// be non-null.
377 final InputDecorationTheme? searchFieldDecorationTheme;
378
379 /// The type of action button to use for the keyboard.
380 ///
381 /// Defaults to the default value specified in [TextField].
382 final TextInputType? keyboardType;
383
384 /// Whether to enable autocorrection.
385 ///
386 /// Defaults to true.
387 final bool autocorrect;
388
389 /// {@macro flutter.services.TextInputConfiguration.enableSuggestions}
390 final bool enableSuggestions;
391
392 /// The text input action configuring the soft keyboard to a particular action
393 /// button.
394 ///
395 /// Defaults to [TextInputAction.search].
396 final TextInputAction textInputAction;
397
398 /// [Animation] triggered when the search pages fades in or out.
399 ///
400 /// This animation is commonly used to animate [AnimatedIcon]s of
401 /// [IconButton]s returned by [buildLeading] or [buildActions]. It can also be
402 /// used to animate [IconButton]s contained within the route below the search
403 /// page.
404 Animation<double> get transitionAnimation => _proxyAnimation;
405
406 // The focus node to use for manipulating focus on the search page. This is
407 // managed, owned, and set by the _SearchPageRoute using this delegate.
408 FocusNode? _focusNode;
409
410 final TextEditingController _queryTextController = TextEditingController();
411
412 final ProxyAnimation _proxyAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
413
414 final ValueNotifier<_SearchBody?> _currentBodyNotifier = ValueNotifier<_SearchBody?>(null);
415
416 _SearchBody? get _currentBody => _currentBodyNotifier.value;
417 set _currentBody(_SearchBody? value) {
418 _currentBodyNotifier.value = value;
419 }
420
421 _SearchPageRoute<T>? _route;
422
423 /// Releases the resources.
424 @mustCallSuper
425 void dispose() {
426 _currentBodyNotifier.dispose();
427 _focusNode?.dispose();
428 _queryTextController.dispose();
429 _proxyAnimation.parent = null;
430 }
431}
432
433/// Describes the body that is currently shown under the [AppBar] in the
434/// search page.
435enum _SearchBody {
436 /// Suggested queries are shown in the body.
437 ///
438 /// The suggested queries are generated by [SearchDelegate.buildSuggestions].
439 suggestions,
440
441 /// Search results are currently shown in the body.
442 ///
443 /// The search results are generated by [SearchDelegate.buildResults].
444 results,
445}
446
447class _SearchPageRoute<T> extends PageRoute<T> {
448 _SearchPageRoute({required this.delegate, required this.maintainState}) {
449 assert(
450 delegate._route == null,
451 'The ${delegate.runtimeType} instance is currently used by another active '
452 'search. Please close that search by calling close() on the SearchDelegate '
453 'before opening another search with the same delegate instance.',
454 );
455 delegate._route = this;
456 }
457
458 final SearchDelegate<T> delegate;
459
460 @override
461 final bool maintainState;
462
463 @override
464 Color? get barrierColor => null;
465
466 @override
467 String? get barrierLabel => null;
468
469 @override
470 Duration get transitionDuration => const Duration(milliseconds: 300);
471
472 @override
473 Widget buildTransitions(
474 BuildContext context,
475 Animation<double> animation,
476 Animation<double> secondaryAnimation,
477 Widget child,
478 ) {
479 return FadeTransition(opacity: animation, child: child);
480 }
481
482 @override
483 Animation<double> createAnimation() {
484 final Animation<double> animation = super.createAnimation();
485 delegate._proxyAnimation.parent = animation;
486 return animation;
487 }
488
489 @override
490 Widget buildPage(
491 BuildContext context,
492 Animation<double> animation,
493 Animation<double> secondaryAnimation,
494 ) {
495 return _SearchPage<T>(delegate: delegate, animation: animation);
496 }
497
498 @override
499 void didComplete(T? result) {
500 super.didComplete(result);
501 assert(delegate._route == this);
502 delegate._route = null;
503 delegate._currentBody = null;
504 }
505}
506
507class _SearchPage<T> extends StatefulWidget {
508 const _SearchPage({required this.delegate, required this.animation});
509
510 final SearchDelegate<T> delegate;
511 final Animation<double> animation;
512
513 @override
514 State<StatefulWidget> createState() => _SearchPageState<T>();
515}
516
517class _SearchPageState<T> extends State<_SearchPage<T>> {
518 // This node is owned, but not hosted by, the search page. Hosting is done by
519 // the text field.
520 late final FocusNode focusNode = FocusNode(
521 onKeyEvent: (FocusNode node, KeyEvent event) {
522 // When the user presses the escape key, close the search page.
523 if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
524 widget.delegate._pop(context);
525 return KeyEventResult.handled;
526 }
527 return KeyEventResult.ignored;
528 },
529 );
530
531 @override
532 void initState() {
533 super.initState();
534 widget.delegate._queryTextController.addListener(_onQueryChanged);
535 widget.animation.addStatusListener(_onAnimationStatusChanged);
536 widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
537 focusNode.addListener(_onFocusChanged);
538 widget.delegate._focusNode = focusNode;
539 }
540
541 @override
542 void dispose() {
543 super.dispose();
544 widget.delegate._queryTextController.removeListener(_onQueryChanged);
545 widget.animation.removeStatusListener(_onAnimationStatusChanged);
546 widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
547 widget.delegate._focusNode = null;
548 focusNode.dispose();
549 }
550
551 void _onAnimationStatusChanged(AnimationStatus status) {
552 if (!status.isCompleted) {
553 return;
554 }
555 widget.animation.removeStatusListener(_onAnimationStatusChanged);
556 if (widget.delegate._currentBody == _SearchBody.suggestions) {
557 focusNode.requestFocus();
558 }
559 }
560
561 @override
562 void didUpdateWidget(_SearchPage<T> oldWidget) {
563 super.didUpdateWidget(oldWidget);
564 if (widget.delegate != oldWidget.delegate) {
565 oldWidget.delegate._queryTextController.removeListener(_onQueryChanged);
566 widget.delegate._queryTextController.addListener(_onQueryChanged);
567 oldWidget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
568 widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
569 oldWidget.delegate._focusNode = null;
570 widget.delegate._focusNode = focusNode;
571 }
572 }
573
574 void _onFocusChanged() {
575 if (focusNode.hasFocus && widget.delegate._currentBody != _SearchBody.suggestions) {
576 widget.delegate.showSuggestions(context);
577 }
578 }
579
580 void _onQueryChanged() {
581 setState(() {
582 // rebuild ourselves because query changed.
583 });
584 }
585
586 void _onSearchBodyChanged() {
587 setState(() {
588 // rebuild ourselves because search body changed.
589 });
590 }
591
592 @override
593 Widget build(BuildContext context) {
594 assert(debugCheckHasMaterialLocalizations(context));
595 final ThemeData theme = widget.delegate.appBarTheme(context);
596 final String searchFieldLabel =
597 widget.delegate.searchFieldLabel ?? MaterialLocalizations.of(context).searchFieldLabel;
598 Widget? body;
599 switch (widget.delegate._currentBody) {
600 case _SearchBody.suggestions:
601 body = KeyedSubtree(
602 key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
603 child: widget.delegate.buildSuggestions(context),
604 );
605 case _SearchBody.results:
606 body = KeyedSubtree(
607 key: const ValueKey<_SearchBody>(_SearchBody.results),
608 child: widget.delegate.buildResults(context),
609 );
610 case null:
611 break;
612 }
613
614 late final String routeName;
615 switch (theme.platform) {
616 case TargetPlatform.iOS:
617 case TargetPlatform.macOS:
618 routeName = '';
619 case TargetPlatform.android:
620 case TargetPlatform.fuchsia:
621 case TargetPlatform.linux:
622 case TargetPlatform.windows:
623 routeName = searchFieldLabel;
624 }
625
626 return Semantics(
627 explicitChildNodes: true,
628 scopesRoute: true,
629 namesRoute: true,
630 label: routeName,
631 child: Theme(
632 data: theme,
633 child: Scaffold(
634 appBar: AppBar(
635 leadingWidth: widget.delegate.leadingWidth,
636 automaticallyImplyLeading: widget.delegate.automaticallyImplyLeading ?? true,
637 leading: widget.delegate.buildLeading(context),
638 title: Semantics(
639 inputType: SemanticsInputType.search,
640 child: TextField(
641 controller: widget.delegate._queryTextController,
642 focusNode: focusNode,
643 style: widget.delegate.searchFieldStyle ?? theme.textTheme.titleLarge,
644 textInputAction: widget.delegate.textInputAction,
645 autocorrect: widget.delegate.autocorrect,
646 enableSuggestions: widget.delegate.enableSuggestions,
647 keyboardType: widget.delegate.keyboardType,
648 onSubmitted: (String _) => widget.delegate.showResults(context),
649 decoration: InputDecoration(hintText: searchFieldLabel),
650 ),
651 ),
652 flexibleSpace: widget.delegate.buildFlexibleSpace(context),
653 actions: widget.delegate.buildActions(context),
654 bottom: widget.delegate.buildBottom(context),
655 ),
656 body: AnimatedSwitcher(duration: const Duration(milliseconds: 300), child: body),
657 ),
658 ),
659 );
660 }
661}
662