blob: 6e9a5a7c6cc8cf43959a2c361bf4511a08414fec [file] [log] [blame]
// Copyright 2018 The Chromium 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/widgets.dart';
import 'app_bar.dart';
import 'colors.dart';
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.
///
/// 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.
///
/// 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.
///
/// See also:
///
/// * [SearchDelegate] to define the content of the search page.
Future<T> showSearch<T>({
@required BuildContext context,
@required SearchDelegate<T> delegate,
String query = '',
}) {
assert(delegate != null);
assert(context != null);
delegate.query = query ?? delegate.query;
delegate._currentBody = _SearchBody.suggestions;
return Navigator.of(context).push(new _SearchPageRoute<T>(
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
/// query text field can be customized via [SearchDelegate.leading] and
/// [SearchDelegate.actions].
///
/// 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.
abstract class SearchDelegate<T> {
/// 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.
///
/// 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.
///
/// Returns null if no widget should be shown
///
/// See also:
///
/// * [AppBar.actions], the intended use for the return value of this method.
List<Widget> buildActions(BuildContext context);
/// The theme used to style the [AppBar].
///
/// By default, a white theme is used.
///
/// See also:
///
/// * [AppBar.backgroundColor], which is set to [ThemeData.primaryColor].
/// * [Appbar.iconTheme], which is set to [ThemeData.primaryIconTheme].
/// * [AppBar.textTheme], which is set to [ThemeData.primaryTextTheme].
/// * [AppBar.brightness], which is set to [ThemeData.primaryColorBrightness].
ThemeData appBarTheme(BuildContext context) {
assert(context != null);
final ThemeData theme = Theme.of(context);
assert(theme != null);
return theme.copyWith(
primaryColor: Colors.white,
primaryIconTheme: theme.primaryIconTheme.copyWith(color: Colors.grey),
primaryColorBrightness: Brightness.light,
primaryTextTheme: theme.textTheme,
);
}
/// The current query string shown in the [Appbar].
///
/// 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) {
_focusNode.unfocus();
_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
/// field of the ApBar.
///
/// 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) {
FocusScope.of(context).requestFocus(_focusNode);
_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;
_focusNode.unfocus();
Navigator.of(context)
..popUntil((Route<dynamic> route) => route == _route)
..pop(result);
}
/// [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;
final FocusNode _focusNode = new FocusNode();
final TextEditingController _queryTextController = new TextEditingController();
final ProxyAnimation _proxyAnimation = new ProxyAnimation(kAlwaysDismissedAnimation);
final ValueNotifier<_SearchBody> _currentBodyNotifier = new ValueNotifier<_SearchBody>(null);
_SearchBody get _currentBody => _currentBodyNotifier.value;
set _currentBody(_SearchBody value) {
_currentBodyNotifier.value = value;
}
_SearchPageRoute<T> _route;
}
/// 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,
}
class _SearchPageRoute<T> extends PageRoute<T> {
_SearchPageRoute({
@required this.delegate,
}) : 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 '
'before openening another search with the same delegate instance.',
);
delegate._route = this;
}
final SearchDelegate<T> delegate;
@override
Color get barrierColor => null;
@override
String get barrierLabel => null;
@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,
) {
return new FadeTransition(
opacity: animation,
child: child,
);
}
@override
Animation<double> createAnimation() {
final Animation<double> animation = super.createAnimation();
delegate._proxyAnimation.parent = animation;
return animation;
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return new _SearchPage<T>(
delegate: delegate,
animation: animation,
);
}
@override
void didComplete(T result) {
super.didComplete(result);
assert(delegate._route == this);
delegate._route = null;
delegate._currentBody = null;
}
}
class _SearchPage<T> extends StatefulWidget {
const _SearchPage({
this.delegate,
this.animation,
});
final SearchDelegate<T> delegate;
final Animation<double> animation;
@override
State<StatefulWidget> createState() => new _SearchPageState<T>();
}
class _SearchPageState<T> extends State<_SearchPage<T>> {
@override
void initState() {
super.initState();
queryTextController.addListener(_onQueryChanged);
widget.animation.addStatusListener(_onAnimationStatusChanged);
widget.delegate._currentBodyNotifier.addListener(_onSearchBodyChanged);
widget.delegate._focusNode.addListener(_onFocusChanged);
}
@override
void dispose() {
super.dispose();
queryTextController.removeListener(_onQueryChanged);
widget.animation.removeStatusListener(_onAnimationStatusChanged);
widget.delegate._currentBodyNotifier.removeListener(_onSearchBodyChanged);
widget.delegate._focusNode.removeListener(_onFocusChanged);
}
void _onAnimationStatusChanged(AnimationStatus status) {
if (status != AnimationStatus.completed) {
return;
}
widget.animation.removeStatusListener(_onAnimationStatusChanged);
if (widget.delegate._currentBody == _SearchBody.suggestions) {
FocusScope.of(context).requestFocus(widget.delegate._focusNode);
}
}
void _onFocusChanged() {
if (widget.delegate._focusNode.hasFocus && widget.delegate._currentBody != _SearchBody.suggestions) {
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) {
final ThemeData theme = widget.delegate.appBarTheme(context);
Widget body;
switch(widget.delegate._currentBody) {
case _SearchBody.suggestions:
body = new KeyedSubtree(
key: const ValueKey<_SearchBody>(_SearchBody.suggestions),
child: widget.delegate.buildSuggestions(context),
);
break;
case _SearchBody.results:
body = new KeyedSubtree(
key: const ValueKey<_SearchBody>(_SearchBody.results),
child: widget.delegate.buildResults(context),
);
break;
}
return new Scaffold(
appBar: new AppBar(
backgroundColor: theme.primaryColor,
iconTheme: theme.primaryIconTheme,
textTheme: theme.primaryTextTheme,
brightness: theme.primaryColorBrightness,
leading: widget.delegate.buildLeading(context),
// TODO(goderbauer): Show the search key (instead of enter) on keyboard, https://github.com/flutter/flutter/issues/17525
title: new TextField(
controller: queryTextController,
focusNode: widget.delegate._focusNode,
style: theme.textTheme.title,
onSubmitted: (String _) {
widget.delegate.showResults(context);
},
decoration: new InputDecoration(
border: InputBorder.none,
hintText: MaterialLocalizations.of(context).searchFieldLabel,
),
),
actions: widget.delegate.buildActions(context),
),
body: new AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: body,
),
);
}
TextEditingController get queryTextController => widget.delegate._queryTextController;
}