search.dart 21.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/widgets.dart';

import 'app_bar.dart';
8 9
import 'app_bar_theme.dart';
import 'color_scheme.dart';
10
import 'colors.dart';
11
import 'debug.dart';
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
import 'input_border.dart';
import 'input_decorator.dart';
import 'material_localizations.dart';
import 'scaffold.dart';
import 'text_field.dart';
import 'theme.dart';

/// Shows a full screen search page and returns the search result selected by
/// the user when the page is closed.
///
/// The search page consists of an app bar with a search field and a body which
/// can either show suggested search queries or the search results.
///
/// The appearance of the search page is determined by the provided
/// `delegate`. The initial query string is given by `query`, which defaults
/// to the empty string. When `query` is set to null, `delegate.query` will
/// be used as the initial query.
///
30 31 32
/// This method returns the selected search result, which can be set in the
/// [SearchDelegate.close] call. If the search page is closed with the system
/// back button, it returns null.
33 34 35 36 37
///
/// A given [SearchDelegate] can only be associated with one active [showSearch]
/// call. Call [SearchDelegate.close] before re-using the same delegate instance
/// for another [showSearch] call.
///
38 39 40 41 42 43
/// The `useRootNavigator` argument is used to determine whether to push the
/// search page to the [Navigator] furthest from or nearest to the given
/// `context`. By default, `useRootNavigator` is `false` and the search page
/// route created by this method is pushed to the nearest navigator to the
/// given `context`. It can not be `null`.
///
44 45 46 47 48 49 50 51 52
/// The transition to the search page triggered by this method looks best if the
/// screen triggering the transition contains an [AppBar] at the top and the
/// transition is called from an [IconButton] that's part of [AppBar.actions].
/// The animation provided by [SearchDelegate.transitionAnimation] can be used
/// to trigger additional animations in the underlying page while the search
/// page fades in or out. This is commonly used to animate an [AnimatedIcon] in
/// the [AppBar.leading] position e.g. from the hamburger menu to the back arrow
/// used to exit the search page.
///
53
/// ## Handling emojis and other complex characters
54
/// {@macro flutter.widgets.EditableText.onChanged}
55
///
56 57 58
/// See also:
///
///  * [SearchDelegate] to define the content of the search page.
59
Future<T?> showSearch<T>({
60 61 62
  required BuildContext context,
  required SearchDelegate<T> delegate,
  String? query = '',
63
  bool useRootNavigator = false,
64 65 66
}) {
  assert(delegate != null);
  assert(context != null);
67
  assert(useRootNavigator != null);
68 69
  delegate.query = query ?? delegate.query;
  delegate._currentBody = _SearchBody.suggestions;
70
  return Navigator.of(context, rootNavigator: useRootNavigator).push(_SearchPageRoute<T>(
71 72 73 74 75 76 77 78
    delegate: delegate,
  ));
}

/// Delegate for [showSearch] to define the content of the search page.
///
/// The search page always shows an [AppBar] at the top where users can
/// enter their search queries. The buttons shown before and after the search
79
/// query text field can be customized via [SearchDelegate.buildLeading]
80
/// and [SearchDelegate.buildActions]. Additionally, a widget can be placed
81
/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom].
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
///
/// The body below the [AppBar] can either show suggested queries (returned by
/// [SearchDelegate.buildSuggestions]) or - once the user submits a search  - the
/// results of the search as returned by [SearchDelegate.buildResults].
///
/// [SearchDelegate.query] always contains the current query entered by the user
/// and should be used to build the suggestions and results.
///
/// The results can be brought on screen by calling [SearchDelegate.showResults]
/// and you can go back to showing the suggestions by calling
/// [SearchDelegate.showSuggestions].
///
/// Once the user has selected a search result, [SearchDelegate.close] should be
/// called to remove the search page from the top of the navigation stack and
/// to notify the caller of [showSearch] about the selected search result.
///
/// A given [SearchDelegate] can only be associated with one active [showSearch]
/// call. Call [SearchDelegate.close] before re-using the same delegate instance
/// for another [showSearch] call.
101 102
///
/// ## Handling emojis and other complex characters
103
/// {@macro flutter.widgets.EditableText.onChanged}
104
abstract class SearchDelegate<T> {
105 106 107 108
  /// Constructor to be called by subclasses which may specify
  /// [searchFieldLabel], either [searchFieldStyle] or [searchFieldDecorationTheme],
  /// [keyboardType] and/or [textInputAction]. Only one of [searchFieldLabel]
  /// and [searchFieldDecorationTheme] may be non-null.
109
  ///
110
  /// {@tool snippet}
111
  /// ```dart
112
  /// class CustomSearchHintDelegate extends SearchDelegate<String> {
113
  ///   CustomSearchHintDelegate({
114
  ///     required String hintText,
115 116 117 118 119 120 121
  ///   }) : super(
  ///     searchFieldLabel: hintText,
  ///     keyboardType: TextInputType.text,
  ///     textInputAction: TextInputAction.search,
  ///   );
  ///
  ///   @override
122
  ///   Widget buildLeading(BuildContext context) => const Text('leading');
123
  ///
124
  ///   @override
125
  ///   PreferredSizeWidget buildBottom(BuildContext context) {
126
  ///     return const PreferredSize(
127
  ///        preferredSize: Size.fromHeight(56.0),
128
  ///        child: Text('bottom'));
129 130
  ///   }
  ///
131
  ///   @override
132
  ///   Widget buildSuggestions(BuildContext context) => const Text('suggestions');
133 134
  ///
  ///   @override
135
  ///   Widget buildResults(BuildContext context) => const Text('results');
136 137
  ///
  ///   @override
138
  ///   List<Widget> buildActions(BuildContext context) => <Widget>[];
139 140 141 142 143
  /// }
  /// ```
  /// {@end-tool}
  SearchDelegate({
    this.searchFieldLabel,
144
    this.searchFieldStyle,
145
    this.searchFieldDecorationTheme,
146 147
    this.keyboardType,
    this.textInputAction = TextInputAction.search,
148
  }) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null);
149

150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
  /// Suggestions shown in the body of the search page while the user types a
  /// query into the search field.
  ///
  /// The delegate method is called whenever the content of [query] changes.
  /// The suggestions should be based on the current [query] string. If the query
  /// string is empty, it is good practice to show suggested queries based on
  /// past queries or the current context.
  ///
  /// Usually, this method will return a [ListView] with one [ListTile] per
  /// suggestion. When [ListTile.onTap] is called, [query] should be updated
  /// with the corresponding suggestion and the results page should be shown
  /// by calling [showResults].
  Widget buildSuggestions(BuildContext context);

  /// The results shown after the user submits a search from the search page.
  ///
  /// The current value of [query] can be used to determine what the user
  /// searched for.
  ///
169 170 171 172
  /// This method might be applied more than once to the same query.
  /// If your [buildResults] method is computationally expensive, you may want
  /// to cache the search results for one or more queries.
  ///
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
  /// Typically, this method returns a [ListView] with the search results.
  /// When the user taps on a particular search result, [close] should be called
  /// with the selected result as argument. This will close the search page and
  /// communicate the result back to the initial caller of [showSearch].
  Widget buildResults(BuildContext context);

  /// A widget to display before the current query in the [AppBar].
  ///
  /// Typically an [IconButton] configured with a [BackButtonIcon] that exits
  /// the search with [close]. One can also use an [AnimatedIcon] driven by
  /// [transitionAnimation], which animates from e.g. a hamburger menu to the
  /// back button as the search overlay fades in.
  ///
  /// Returns null if no widget should be shown.
  ///
  /// See also:
  ///
  ///  * [AppBar.leading], the intended use for the return value of this method.
191
  Widget? buildLeading(BuildContext context);
192 193 194 195 196 197 198

  /// Widgets to display after the search query in the [AppBar].
  ///
  /// If the [query] is not empty, this should typically contain a button to
  /// clear the query and show the suggestions again (via [showSuggestions]) if
  /// the results are currently shown.
  ///
199
  /// Returns null if no widget should be shown.
200 201 202 203
  ///
  /// See also:
  ///
  ///  * [AppBar.actions], the intended use for the return value of this method.
204
  List<Widget>? buildActions(BuildContext context);
205

206 207 208 209 210 211 212 213 214 215
  /// Widget to display across the bottom of the [AppBar].
  ///
  /// Returns null by default, i.e. a bottom widget is not included.
  ///
  /// See also:
  ///
  ///  * [AppBar.bottom], the intended use for the return value of this method.
  ///
  PreferredSizeWidget? buildBottom(BuildContext context) => null;

216 217 218 219 220
  /// The theme used to configure the search page.
  ///
  /// The returned [ThemeData] will be used to wrap the entire search page,
  /// so it can be used to configure any of its components with the appropriate
  /// theme properties.
221
  ///
222 223 224 225
  /// Unless overridden, the default theme will configure the AppBar containing
  /// the search input text field with a white background and black text on light
  /// themes. For dark themes the default is a dark grey background with light
  /// color text.
226 227 228
  ///
  /// See also:
  ///
229 230 231
  ///  * [AppBarTheme], which configures the AppBar's appearance.
  ///  * [InputDecorationTheme], which configures the appearance of the search
  ///    text field.
232 233
  ThemeData appBarTheme(BuildContext context) {
    assert(context != null);
234
    final ThemeData theme = Theme.of(context);
235
    final ColorScheme colorScheme = theme.colorScheme;
236 237
    assert(theme != null);
    return theme.copyWith(
238 239
      appBarTheme: AppBarTheme(
        brightness: colorScheme.brightness,
240
        backgroundColor: colorScheme.brightness == Brightness.dark ? Colors.grey[900] : Colors.white,
241 242 243 244 245 246 247 248
        iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),
        textTheme: theme.textTheme,
      ),
      inputDecorationTheme: searchFieldDecorationTheme ??
          InputDecorationTheme(
            hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle,
            border: InputBorder.none,
          ),
249 250 251
    );
  }

252
  /// The current query string shown in the [AppBar].
253 254 255 256 257 258
  ///
  /// The user manipulates this string via the keyboard.
  ///
  /// If the user taps on a suggestion provided by [buildSuggestions] this
  /// string should be updated to that suggestion via the setter.
  String get query => _queryTextController.text;
259 260 261 262

  /// Changes the current query string.
  ///
  /// Setting the query string programmatically moves the cursor to the end of the text field.
263 264 265
  set query(String value) {
    assert(query != null);
    _queryTextController.text = value;
266
    _queryTextController.selection = TextSelection.fromPosition(TextPosition(offset: _queryTextController.text.length));
267 268 269 270 271 272 273 274 275 276 277 278 279 280
  }

  /// Transition from the suggestions returned by [buildSuggestions] to the
  /// [query] results returned by [buildResults].
  ///
  /// If the user taps on a suggestion provided by [buildSuggestions] the
  /// screen should typically transition to the page showing the search
  /// results for the suggested query. This transition can be triggered
  /// by calling this method.
  ///
  /// See also:
  ///
  ///  * [showSuggestions] to show the search suggestions again.
  void showResults(BuildContext context) {
281
    _focusNode?.unfocus();
282 283 284 285 286 287 288
    _currentBody = _SearchBody.results;
  }

  /// Transition from showing the results returned by [buildResults] to showing
  /// the suggestions returned by [buildSuggestions].
  ///
  /// Calling this method will also put the input focus back into the search
289
  /// field of the [AppBar].
290 291 292 293 294 295 296 297
  ///
  /// If the results are currently shown this method can be used to go back
  /// to showing the search suggestions.
  ///
  /// See also:
  ///
  ///  * [showResults] to show the search results.
  void showSuggestions(BuildContext context) {
298
    assert(_focusNode != null, '_focusNode must be set by route before showSuggestions is called.');
299
    _focusNode!.requestFocus();
300 301 302 303 304 305 306 307 308
    _currentBody = _SearchBody.suggestions;
  }

  /// Closes the search page and returns to the underlying route.
  ///
  /// The value provided for `result` is used as the return value of the call
  /// to [showSearch] that launched the search initially.
  void close(BuildContext context, T result) {
    _currentBody = null;
309
    _focusNode?.unfocus();
310
    Navigator.of(context)
311 312 313 314
      ..popUntil((Route<dynamic> route) => route == _route)
      ..pop(result);
  }

315 316
  /// The hint text that is shown in the search field when it is empty.
  ///
317 318
  /// If this value is set to null, the value of
  /// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead.
319
  final String? searchFieldLabel;
320

321 322
  /// The style of the [searchFieldLabel].
  ///
323 324
  /// If this value is set to null, the value of the ambient [Theme]'s
  /// [InputDecorationTheme.hintStyle] will be used instead.
325 326 327
  ///
  /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
  /// be non-null.
328
  final TextStyle? searchFieldStyle;
329

330 331 332 333 334 335
  /// The [InputDecorationTheme] used to configure the search field's visuals.
  ///
  /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
  /// be non-null.
  final InputDecorationTheme? searchFieldDecorationTheme;

336 337 338
  /// The type of action button to use for the keyboard.
  ///
  /// Defaults to the default value specified in [TextField].
339
  final TextInputType? keyboardType;
340 341 342 343 344 345 346

  /// The text input action configuring the soft keyboard to a particular action
  /// button.
  ///
  /// Defaults to [TextInputAction.search].
  final TextInputAction textInputAction;

347 348 349 350 351 352 353 354
  /// [Animation] triggered when the search pages fades in or out.
  ///
  /// This animation is commonly used to animate [AnimatedIcon]s of
  /// [IconButton]s returned by [buildLeading] or [buildActions]. It can also be
  /// used to animate [IconButton]s contained within the route below the search
  /// page.
  Animation<double> get transitionAnimation => _proxyAnimation;

355 356
  // The focus node to use for manipulating focus on the search page. This is
  // managed, owned, and set by the _SearchPageRoute using this delegate.
357
  FocusNode? _focusNode;
358

359
  final TextEditingController _queryTextController = TextEditingController();
360

361
  final ProxyAnimation _proxyAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
362

363
  final ValueNotifier<_SearchBody?> _currentBodyNotifier = ValueNotifier<_SearchBody?>(null);
364

365 366
  _SearchBody? get _currentBody => _currentBodyNotifier.value;
  set _currentBody(_SearchBody? value) {
367 368 369
    _currentBodyNotifier.value = value;
  }

370
  _SearchPageRoute<T>? _route;
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386
}

/// Describes the body that is currently shown under the [AppBar] in the
/// search page.
enum _SearchBody {
  /// Suggested queries are shown in the body.
  ///
  /// The suggested queries are generated by [SearchDelegate.buildSuggestions].
  suggestions,

  /// Search results are currently shown in the body.
  ///
  /// The search results are generated by [SearchDelegate.buildResults].
  results,
}

387
class _SearchPageRoute<T> extends PageRoute<T> {
388
  _SearchPageRoute({
389
    required this.delegate,
390 391 392 393 394
  }) : assert(delegate != null) {
    assert(
      delegate._route == null,
      'The ${delegate.runtimeType} instance is currently used by another active '
      'search. Please close that search by calling close() on the SearchDelegate '
395
      'before opening another search with the same delegate instance.',
396 397 398 399 400 401 402
    );
    delegate._route = this;
  }

  final SearchDelegate<T> delegate;

  @override
403
  Color? get barrierColor => null;
404 405

  @override
406
  String? get barrierLabel => null;
407 408 409 410 411 412 413 414 415 416 417 418 419 420

  @override
  Duration get transitionDuration => const Duration(milliseconds: 300);

  @override
  bool get maintainState => false;

  @override
  Widget buildTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child,
  ) {
421
    return FadeTransition(
422 423 424 425 426 427 428 429 430 431 432 433 434 435
      opacity: animation,
      child: child,
    );
  }

  @override
  Animation<double> createAnimation() {
    final Animation<double> animation = super.createAnimation();
    delegate._proxyAnimation.parent = animation;
    return animation;
  }

  @override
  Widget buildPage(
436 437 438 439
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
440
    return _SearchPage<T>(
441 442 443 444 445 446
      delegate: delegate,
      animation: animation,
    );
  }

  @override
447
  void didComplete(T? result) {
448 449 450
    super.didComplete(result);
    assert(delegate._route == this);
    delegate._route = null;
451
    delegate._currentBody = null;
452 453 454 455 456
  }
}

class _SearchPage<T> extends StatefulWidget {
  const _SearchPage({
457 458
    required this.delegate,
    required this.animation,
459 460 461 462 463 464
  });

  final SearchDelegate<T> delegate;
  final Animation<double> animation;

  @override
465
  State<StatefulWidget> createState() => _SearchPageState<T>();
466 467 468
}

class _SearchPageState<T> extends State<_SearchPage<T>> {
469 470 471 472
  // This node is owned, but not hosted by, the search page. Hosting is done by
  // the text field.
  FocusNode focusNode = FocusNode();

473 474 475
  @override
  void initState() {
    super.initState();
476
    widget.delegate._queryTextController.addListener(_onQueryChanged);
477 478
    widget.animation.addStatusListener(_onAnimationStatusChanged);
    widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
479 480
    focusNode.addListener(_onFocusChanged);
    widget.delegate._focusNode = focusNode;
481 482 483 484 485
  }

  @override
  void dispose() {
    super.dispose();
486
    widget.delegate._queryTextController.removeListener(_onQueryChanged);
487 488
    widget.animation.removeStatusListener(_onAnimationStatusChanged);
    widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
489 490
    widget.delegate._focusNode = null;
    focusNode.dispose();
491 492 493 494 495 496 497 498
  }

  void _onAnimationStatusChanged(AnimationStatus status) {
    if (status != AnimationStatus.completed) {
      return;
    }
    widget.animation.removeStatusListener(_onAnimationStatusChanged);
    if (widget.delegate._currentBody == _SearchBody.suggestions) {
499
      focusNode.requestFocus();
500 501 502
    }
  }

503 504 505 506 507 508 509 510 511 512 513 514 515
  @override
  void didUpdateWidget(_SearchPage<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.delegate != oldWidget.delegate) {
      oldWidget.delegate._queryTextController.removeListener(_onQueryChanged);
      widget.delegate._queryTextController.addListener(_onQueryChanged);
      oldWidget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
      widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
      oldWidget.delegate._focusNode = null;
      widget.delegate._focusNode = focusNode;
    }
  }

516
  void _onFocusChanged() {
517
    if (focusNode.hasFocus && widget.delegate._currentBody != _SearchBody.suggestions) {
518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535
      widget.delegate.showSuggestions(context);
    }
  }

  void _onQueryChanged() {
    setState(() {
      // rebuild ourselves because query changed.
    });
  }

  void _onSearchBodyChanged() {
    setState(() {
      // rebuild ourselves because search body changed.
    });
  }

  @override
  Widget build(BuildContext context) {
536
    assert(debugCheckHasMaterialLocalizations(context));
537
    final ThemeData theme = widget.delegate.appBarTheme(context);
538
    final String searchFieldLabel = widget.delegate.searchFieldLabel
539
      ?? MaterialLocalizations.of(context).searchFieldLabel;
540
    Widget? body;
541 542
    switch(widget.delegate._currentBody) {
      case _SearchBody.suggestions:
543
        body = KeyedSubtree(
544 545 546 547 548
          key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
          child: widget.delegate.buildSuggestions(context),
        );
        break;
      case _SearchBody.results:
549
        body = KeyedSubtree(
550 551 552 553
          key: const ValueKey<_SearchBody>(_SearchBody.results),
          child: widget.delegate.buildResults(context),
        );
        break;
554 555
      case null:
        break;
556
    }
557 558

    late final String routeName;
559
    switch (theme.platform) {
560
      case TargetPlatform.iOS:
561
      case TargetPlatform.macOS:
562 563 564 565
        routeName = '';
        break;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
566 567
      case TargetPlatform.linux:
      case TargetPlatform.windows:
568 569
        routeName = searchFieldLabel;
    }
570

571
    return Semantics(
572 573 574 575
      explicitChildNodes: true,
      scopesRoute: true,
      namesRoute: true,
      label: routeName,
576 577 578 579 580 581 582 583 584 585 586 587 588 589 590
      child: Theme(
        data: theme,
        child: Scaffold(
          appBar: AppBar(
            leading: widget.delegate.buildLeading(context),
            title: TextField(
              controller: widget.delegate._queryTextController,
              focusNode: focusNode,
              style: theme.textTheme.headline6,
              textInputAction: widget.delegate.textInputAction,
              keyboardType: widget.delegate.keyboardType,
              onSubmitted: (String _) {
                widget.delegate.showResults(context);
              },
              decoration: InputDecoration(hintText: searchFieldLabel),
591
            ),
592
            actions: widget.delegate.buildActions(context),
593
            bottom: widget.delegate.buildBottom(context),
594 595 596 597
          ),
          body: AnimatedSwitcher(
            duration: const Duration(milliseconds: 300),
            child: body,
598
          ),
599
        ),
600 601 602 603
      ),
    );
  }
}