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

5
import 'package:flutter/foundation.dart';
6 7 8
import 'package:flutter/widgets.dart';

import 'app_bar.dart';
9 10
import 'app_bar_theme.dart';
import 'color_scheme.dart';
11
import 'colors.dart';
12
import 'debug.dart';
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
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.
///
31 32 33
/// 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.
34 35 36 37 38 39 40 41 42 43 44 45 46 47
///
/// 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.
///
/// 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.
///
48
/// ## Handling emojis and other complex characters
49
/// {@macro flutter.widgets.EditableText.onChanged}
50
///
51 52 53
/// See also:
///
///  * [SearchDelegate] to define the content of the search page.
54
Future<T?> showSearch<T>({
55 56 57
  required BuildContext context,
  required SearchDelegate<T> delegate,
  String? query = '',
58 59 60 61 62
}) {
  assert(delegate != null);
  assert(context != null);
  delegate.query = query ?? delegate.query;
  delegate._currentBody = _SearchBody.suggestions;
63
  return Navigator.of(context).push(_SearchPageRoute<T>(
64 65 66 67 68 69 70 71
    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
72 73 74
/// query text field can be customized via [SearchDelegate.buildLeading]
/// and [SearchDelegate.buildActions]. Additonally, a widget can be placed
/// across the bottom of the [AppBar] via [SearchDelegate.buildBottom].
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
///
/// 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.
94 95
///
/// ## Handling emojis and other complex characters
96
/// {@macro flutter.widgets.EditableText.onChanged}
97
abstract class SearchDelegate<T> {
98 99 100 101
  /// 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.
102
  ///
103
  /// {@tool snippet}
104 105 106
  /// ```dart
  /// class CustomSearchHintDelegate extends SearchDelegate {
  ///   CustomSearchHintDelegate({
107
  ///     required String hintText,
108 109 110 111 112 113 114 115 116
  ///   }) : super(
  ///     searchFieldLabel: hintText,
  ///     keyboardType: TextInputType.text,
  ///     textInputAction: TextInputAction.search,
  ///   );
  ///
  ///   @override
  ///   Widget buildLeading(BuildContext context) => Text("leading");
  ///
117 118 119 120 121 122
  ///   PreferredSizeWidget buildBottom(BuildContext context) {
  ///     return PreferredSize(
  ///        preferredSize: Size.fromHeight(56.0),
  ///        child: Text("bottom"));
  ///   }
  ///
123 124 125 126 127 128 129 130 131 132 133 134 135
  ///   @override
  ///   Widget buildSuggestions(BuildContext context) => Text("suggestions");
  ///
  ///   @override
  ///   Widget buildResults(BuildContext context) => Text('results');
  ///
  ///   @override
  ///   List<Widget> buildActions(BuildContext context) => [];
  /// }
  /// ```
  /// {@end-tool}
  SearchDelegate({
    this.searchFieldLabel,
136
    this.searchFieldStyle,
137
    this.searchFieldDecorationTheme,
138 139
    this.keyboardType,
    this.textInputAction = TextInputAction.search,
140
  }) : assert(searchFieldStyle == null || searchFieldDecorationTheme == null);
141

142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160
  /// 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.
  ///
161 162 163 164
  /// 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.
  ///
165 166 167 168 169 170 171 172 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.
  Widget buildLeading(BuildContext context);

  /// 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.
  ///
191
  /// Returns null if no widget should be shown.
192 193 194 195 196 197
  ///
  /// See also:
  ///
  ///  * [AppBar.actions], the intended use for the return value of this method.
  List<Widget> buildActions(BuildContext context);

198 199 200 201 202 203 204 205 206 207
  /// 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;

208 209 210 211 212
  /// 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.
213
  ///
214 215 216 217
  /// 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.
218 219 220
  ///
  /// See also:
  ///
221 222 223
  ///  * [AppBarTheme], which configures the AppBar's appearance.
  ///  * [InputDecorationTheme], which configures the appearance of the search
  ///    text field.
224 225
  ThemeData appBarTheme(BuildContext context) {
    assert(context != null);
226
    final ThemeData theme = Theme.of(context);
227
    final ColorScheme colorScheme = theme.colorScheme;
228 229
    assert(theme != null);
    return theme.copyWith(
230 231
      appBarTheme: AppBarTheme(
        brightness: colorScheme.brightness,
232
        backgroundColor: colorScheme.brightness == Brightness.dark ? Colors.grey[900] : Colors.white,
233 234 235 236 237 238 239 240
        iconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),
        textTheme: theme.textTheme,
      ),
      inputDecorationTheme: searchFieldDecorationTheme ??
          InputDecorationTheme(
            hintStyle: searchFieldStyle ?? theme.inputDecorationTheme.hintStyle,
            border: InputBorder.none,
          ),
241 242 243
    );
  }

244
  /// The current query string shown in the [AppBar].
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
  ///
  /// 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;
  set query(String value) {
    assert(query != null);
    _queryTextController.text = value;
  }

  /// 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) {
268
    _focusNode?.unfocus();
269 270 271 272 273 274 275
    _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
276
  /// field of the [AppBar].
277 278 279 280 281 282 283 284
  ///
  /// 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) {
285
    assert(_focusNode != null, '_focusNode must be set by route before showSuggestions is called.');
286
    _focusNode!.requestFocus();
287 288 289 290 291 292 293 294 295
    _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;
296
    _focusNode?.unfocus();
297
    Navigator.of(context)
298 299 300 301
      ..popUntil((Route<dynamic> route) => route == _route)
      ..pop(result);
  }

302 303
  /// The hint text that is shown in the search field when it is empty.
  ///
304 305
  /// If this value is set to null, the value of
  /// `MaterialLocalizations.of(context).searchFieldLabel` will be used instead.
306
  final String? searchFieldLabel;
307

308 309
  /// The style of the [searchFieldLabel].
  ///
310 311
  /// If this value is set to null, the value of the ambient [Theme]'s
  /// [InputDecorationTheme.hintStyle] will be used instead.
312 313 314
  ///
  /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
  /// be non-null.
315
  final TextStyle? searchFieldStyle;
316

317 318 319 320 321 322
  /// The [InputDecorationTheme] used to configure the search field's visuals.
  ///
  /// Only one of [searchFieldStyle] or [searchFieldDecorationTheme] can
  /// be non-null.
  final InputDecorationTheme? searchFieldDecorationTheme;

323 324 325
  /// The type of action button to use for the keyboard.
  ///
  /// Defaults to the default value specified in [TextField].
326
  final TextInputType? keyboardType;
327 328 329 330 331 332 333

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

334 335 336 337 338 339 340 341
  /// [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;

342 343
  // The focus node to use for manipulating focus on the search page. This is
  // managed, owned, and set by the _SearchPageRoute using this delegate.
344
  FocusNode? _focusNode;
345

346
  final TextEditingController _queryTextController = TextEditingController();
347

348
  final ProxyAnimation _proxyAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
349

350
  final ValueNotifier<_SearchBody?> _currentBodyNotifier = ValueNotifier<_SearchBody?>(null);
351

352 353
  _SearchBody? get _currentBody => _currentBodyNotifier.value;
  set _currentBody(_SearchBody? value) {
354 355 356
    _currentBodyNotifier.value = value;
  }

357
  _SearchPageRoute<T>? _route;
358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
}

/// 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,
}

374
class _SearchPageRoute<T> extends PageRoute<T> {
375
  _SearchPageRoute({
376
    required this.delegate,
377 378 379 380 381
  }) : 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 '
382
      'before opening another search with the same delegate instance.',
383 384 385 386 387 388 389
    );
    delegate._route = this;
  }

  final SearchDelegate<T> delegate;

  @override
390
  Color? get barrierColor => null;
391 392

  @override
393
  String? get barrierLabel => null;
394 395 396 397 398 399 400 401 402 403 404 405 406 407

  @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,
  ) {
408
    return FadeTransition(
409 410 411 412 413 414 415 416 417 418 419 420 421 422
      opacity: animation,
      child: child,
    );
  }

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

  @override
  Widget buildPage(
423 424 425 426
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
  ) {
427
    return _SearchPage<T>(
428 429 430 431 432 433
      delegate: delegate,
      animation: animation,
    );
  }

  @override
434
  void didComplete(T? result) {
435 436 437
    super.didComplete(result);
    assert(delegate._route == this);
    delegate._route = null;
438
    delegate._currentBody = null;
439 440 441 442 443
  }
}

class _SearchPage<T> extends StatefulWidget {
  const _SearchPage({
444 445
    required this.delegate,
    required this.animation,
446 447 448 449 450 451
  });

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

  @override
452
  State<StatefulWidget> createState() => _SearchPageState<T>();
453 454 455
}

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

460 461 462
  @override
  void initState() {
    super.initState();
463
    widget.delegate._queryTextController.addListener(_onQueryChanged);
464 465
    widget.animation.addStatusListener(_onAnimationStatusChanged);
    widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
466 467
    focusNode.addListener(_onFocusChanged);
    widget.delegate._focusNode = focusNode;
468 469 470 471 472
  }

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

  void _onAnimationStatusChanged(AnimationStatus status) {
    if (status != AnimationStatus.completed) {
      return;
    }
    widget.animation.removeStatusListener(_onAnimationStatusChanged);
    if (widget.delegate._currentBody == _SearchBody.suggestions) {
486
      focusNode.requestFocus();
487 488 489
    }
  }

490 491 492 493 494 495 496 497 498 499 500 501 502
  @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;
    }
  }

503
  void _onFocusChanged() {
504
    if (focusNode.hasFocus && widget.delegate._currentBody != _SearchBody.suggestions) {
505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522
      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) {
523
    assert(debugCheckHasMaterialLocalizations(context));
524
    final ThemeData theme = widget.delegate.appBarTheme(context);
525
    final String searchFieldLabel = widget.delegate.searchFieldLabel
526
      ?? MaterialLocalizations.of(context).searchFieldLabel;
527
    Widget? body;
528 529
    switch(widget.delegate._currentBody) {
      case _SearchBody.suggestions:
530
        body = KeyedSubtree(
531 532 533 534 535
          key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
          child: widget.delegate.buildSuggestions(context),
        );
        break;
      case _SearchBody.results:
536
        body = KeyedSubtree(
537 538 539 540
          key: const ValueKey<_SearchBody>(_SearchBody.results),
          child: widget.delegate.buildResults(context),
        );
        break;
541 542
      case null:
        break;
543
    }
544 545

    late final String routeName;
546
    switch (theme.platform) {
547
      case TargetPlatform.iOS:
548
      case TargetPlatform.macOS:
549 550 551 552
        routeName = '';
        break;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
553 554
      case TargetPlatform.linux:
      case TargetPlatform.windows:
555 556
        routeName = searchFieldLabel;
    }
557

558
    return Semantics(
559 560 561 562
      explicitChildNodes: true,
      scopesRoute: true,
      namesRoute: true,
      label: routeName,
563 564 565 566 567 568 569 570 571 572 573 574 575 576 577
      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),
578
            ),
579
            actions: widget.delegate.buildActions(context),
580
            bottom: widget.delegate.buildBottom(context),
581 582 583 584
          ),
          body: AnimatedSwitcher(
            duration: const Duration(milliseconds: 300),
            child: body,
585
          ),
586
        )
587 588 589 590
      ),
    );
  }
}