diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index 29547bf4060..506aeb3acc3 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -46,6 +46,7 @@ import 'package:gen_defaults/popup_menu_template.dart'; import 'package:gen_defaults/progress_indicator_template.dart'; import 'package:gen_defaults/radio_template.dart'; import 'package:gen_defaults/search_bar_template.dart'; +import 'package:gen_defaults/search_view_template.dart'; import 'package:gen_defaults/segmented_button_template.dart'; import 'package:gen_defaults/slider_template.dart'; import 'package:gen_defaults/snackbar_template.dart'; @@ -177,6 +178,7 @@ Future main(List args) async { ProgressIndicatorTemplate('ProgressIndicator', '$materialLib/progress_indicator.dart', tokens).updateFile(); RadioTemplate('Radio', '$materialLib/radio.dart', tokens).updateFile(); SearchBarTemplate('SearchBar', '$materialLib/search_anchor.dart', tokens).updateFile(); + SearchViewTemplate('SearchView', '$materialLib/search_anchor.dart', tokens).updateFile(); SegmentedButtonTemplate('md.comp.outlined-segmented-button', 'SegmentedButton', '$materialLib/segmented_button.dart', tokens).updateFile(); SnackbarTemplate('md.comp.snackbar', 'Snackbar', '$materialLib/snack_bar.dart', tokens).updateFile(); SliderTemplate('md.comp.slider', 'Slider', '$materialLib/slider.dart', tokens).updateFile(); diff --git a/dev/tools/gen_defaults/lib/search_view_template.dart b/dev/tools/gen_defaults/lib/search_view_template.dart new file mode 100644 index 00000000000..551f5c8ede0 --- /dev/null +++ b/dev/tools/gen_defaults/lib/search_view_template.dart @@ -0,0 +1,54 @@ +// 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 'template.dart'; + +class SearchViewTemplate extends TokenTemplate { + const SearchViewTemplate(super.blockName, super.fileName, super.tokens, { + super.colorSchemePrefix = '_colors.', + super.textThemePrefix = '_textTheme.' + }); + + @override + String generate() => ''' +class _${blockName}DefaultsM3 extends ${blockName}ThemeData { + _${blockName}DefaultsM3(this.context, {required this.isFullScreen}); + + final BuildContext context; + final bool isFullScreen; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + static double fullScreenBarHeight = ${tokens['md.comp.search-view.full-screen.header.container.height']}; + + @override + Color? get backgroundColor => ${componentColor('md.comp.search-view.container')}; + + @override + double? get elevation => ${elevation('md.comp.search-view.container')}; + + @override + Color? get surfaceTintColor => ${colorOrTransparent('md.comp.search-view.container.surface-tint-layer.color')}; + + // No default side + + @override + OutlinedBorder? get shape => isFullScreen + ? ${shape('md.comp.search-view.full-screen.container')} + : ${shape('md.comp.search-view.docked.container')}; + + @override + TextStyle? get headerTextStyle => ${textStyleWithColor('md.comp.search-view.header.input-text')}; + + @override + TextStyle? get headerHintStyle => ${textStyleWithColor('md.comp.search-view.header.supporting-text')}; + + @override + BoxConstraints get constraints => const BoxConstraints(minWidth: 360.0, minHeight: 240.0); + + @override + Color? get dividerColor => ${componentColor('md.comp.search-view.divider')}; +} +'''; +} diff --git a/examples/api/lib/material/search_anchor/search_anchor.0.dart b/examples/api/lib/material/search_anchor/search_anchor.0.dart new file mode 100644 index 00000000000..6512817c6cc --- /dev/null +++ b/examples/api/lib/material/search_anchor/search_anchor.0.dart @@ -0,0 +1,124 @@ +// 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. + +// Flutter code sample for [SearchAnchor.bar]. + +import 'package:flutter/material.dart'; + +void main() => runApp(const SearchBarApp()); + +class SearchBarApp extends StatefulWidget { + const SearchBarApp({super.key}); + + @override + State createState() => _SearchBarAppState(); +} + +class _SearchBarAppState extends State { + Color? selectedColorSeed; + List searchHistory = []; + + Iterable getHistoryList(SearchController controller) { + return searchHistory.map((ColorLabel color) => ListTile( + leading: const Icon(Icons.history), + title: Text(color.label), + trailing: IconButton(icon: const Icon(Icons.call_missed), onPressed: () { + controller.text = color.label; + controller.selection = TextSelection.collapsed(offset: controller.text.length); + }), + )); + } + + Iterable getSuggestions(SearchController controller) { + final String input = controller.value.text; + return ColorLabel.values.where((ColorLabel color) => color.label.contains(input)) + .map((ColorLabel filteredColor) => + ListTile( + leading: CircleAvatar(backgroundColor: filteredColor.color), + title: Text(filteredColor.label), + trailing: IconButton(icon: const Icon(Icons.call_missed), onPressed: () { + controller.text = filteredColor.label; + controller.selection = TextSelection.collapsed(offset: controller.text.length); + }), + onTap: () { + controller.closeView(filteredColor.label); + handleSelection(filteredColor); + }, + )); + } + + void handleSelection(ColorLabel selectedColor) { + setState(() { + selectedColorSeed = selectedColor.color; + if (searchHistory.length >= 5) { + searchHistory.removeLast(); + } + searchHistory.insert(0, selectedColor); + }); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = ThemeData(useMaterial3: true, colorSchemeSeed: selectedColorSeed); + final ColorScheme colors = themeData.colorScheme; + + return MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Search Bar Sample')), + body: Align( + alignment: Alignment.topCenter, + child: Column( + children: [ + SearchAnchor.bar( + barHintText: 'Search colors', + suggestionsBuilder: (BuildContext context, SearchController controller) { + if (controller.text.isEmpty) { + if (searchHistory.isNotEmpty) { + return getHistoryList(controller); + } + return [ Center(child: Text('No search history.', style: TextStyle(color: colors.outline))) ]; + } + return getSuggestions(controller); + }, + ), + cardSize, + Card(color: colors.primary, child: cardSize), + Card(color: colors.onPrimary, child: cardSize), + Card(color: colors.primaryContainer, child: cardSize), + Card(color: colors.onPrimaryContainer, child: cardSize), + Card(color: colors.secondary, child: cardSize), + Card(color: colors.onSecondary, child: cardSize), + ], + ), + ), + ), + ); + } +} + +SizedBox cardSize = const SizedBox(width: 80, height: 30,); + +enum ColorLabel { + red('red', Colors.red), + orange('orange', Colors.orange), + yellow('yellow', Colors.yellow), + green('green', Colors.green), + blue('blue', Colors.blue), + indigo('indigo', Colors.indigo), + violet('violet', Color(0xFF8F00FF)), + purple('purple', Colors.purple), + pink('pink', Colors.pink), + silver('silver', Color(0xFF808080)), + gold('gold', Color(0xFFFFD700)), + beige('beige', Color(0xFFF5F5DC)), + brown('brown', Colors.brown), + grey('grey', Colors.grey), + black('black', Colors.black), + white('white', Colors.white); + + const ColorLabel(this.label, this.color); + final String label; + final Color color; +} diff --git a/examples/api/lib/material/search_anchor/search_anchor.1.dart b/examples/api/lib/material/search_anchor/search_anchor.1.dart new file mode 100644 index 00000000000..977c3dbd1e0 --- /dev/null +++ b/examples/api/lib/material/search_anchor/search_anchor.1.dart @@ -0,0 +1,87 @@ +// 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. + +// Flutter code sample for pinned [SearchAnchor] while scrolling. + +import 'package:flutter/material.dart'; + +void main() { + runApp(const PinnedSearchBarApp()); +} + +class PinnedSearchBarApp extends StatefulWidget { + const PinnedSearchBarApp({super.key}); + + @override + State createState() => _PinnedSearchBarAppState(); +} + +class _PinnedSearchBarAppState extends State { + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + useMaterial3: true, + colorSchemeSeed: const Color(0xff6750a4) + ), + home: Scaffold( + body: SafeArea( + child: CustomScrollView( + slivers: [ + SliverAppBar( + clipBehavior: Clip.none, + shape: const StadiumBorder(), + scrolledUnderElevation: 0.0, + titleSpacing: 0.0, + backgroundColor: Colors.transparent, + floating: true, // We can also uncomment this line and set `pinned` to true to see a pinned search bar. + title: SearchAnchor.bar( + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List.generate(5, (int index) { + return ListTile( + titleAlignment: ListTileTitleAlignment.center, + title: Text('Initial list item $index'), + ); + }); + } + ), + ), + // The listed items below are just for filling the screen + // so we can see the scrolling effect. + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20), + child: SizedBox( + height: 100.0, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 10, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + width: 100.0, + child: Card( + child: Center(child: Text('Card $index')), + ), + ); + }, + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + height: 1000, + color: Colors.deepPurple.withOpacity(0.5), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/examples/api/lib/material/search_anchor/search_anchor.2.dart b/examples/api/lib/material/search_anchor/search_anchor.2.dart new file mode 100644 index 00000000000..69827c08534 --- /dev/null +++ b/examples/api/lib/material/search_anchor/search_anchor.2.dart @@ -0,0 +1,65 @@ +// 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. + +// Flutter code sample for [SearchAnchor]. + +import 'package:flutter/material.dart'; + +void main() => runApp(const SearchBarApp()); + +class SearchBarApp extends StatefulWidget { + const SearchBarApp({super.key}); + + @override + State createState() => _SearchBarAppState(); +} + +class _SearchBarAppState extends State { + final SearchController controller = SearchController(); + + @override + Widget build(BuildContext context) { + final ThemeData themeData = ThemeData(useMaterial3: true); + + return MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar(title: const Text('Search Anchor Sample')), + body: Column( + children: [ + SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return List.generate(5, (int index) { + final String item = 'item $index'; + return ListTile( + title: Text(item), + onTap: () { + setState(() { + controller.closeView(item); + }); + }, + ); + }); + } + ), + Center( + child: controller.text.isEmpty + ? const Text('No item selected') + : Text('Selected item: ${controller.value.text}'), + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index bbb851185a8..8ca80a73640 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -154,6 +154,7 @@ export 'src/material/scrollbar_theme.dart'; export 'src/material/search.dart'; export 'src/material/search_anchor.dart'; export 'src/material/search_bar_theme.dart'; +export 'src/material/search_view_theme.dart'; export 'src/material/segmented_button.dart'; export 'src/material/segmented_button_theme.dart'; export 'src/material/selectable_text.dart'; diff --git a/packages/flutter/lib/src/material/app_bar.dart b/packages/flutter/lib/src/material/app_bar.dart index 3c3d4e88bb8..53705ebfb19 100644 --- a/packages/flutter/lib/src/material/app_bar.dart +++ b/packages/flutter/lib/src/material/app_bar.dart @@ -204,6 +204,7 @@ class AppBar extends StatefulWidget implements PreferredSizeWidget { this.titleTextStyle, this.systemOverlayStyle, this.forceMaterialTransparency = false, + this.clipBehavior, }) : assert(elevation == null || elevation >= 0.0), preferredSize = _PreferredAppBarSize(toolbarHeight, bottom?.preferredSize.height); @@ -714,6 +715,9 @@ class AppBar extends StatefulWidget implements PreferredSizeWidget { /// {@endtemplate} final bool forceMaterialTransparency; + /// {@macro flutter.material.Material.clipBehavior} + final Clip? clipBehavior; + bool _getEffectiveCenterTitle(ThemeData theme) { bool platformCenter() { switch (theme.platform) { @@ -1044,6 +1048,7 @@ class _AppBarState extends State { // If the toolbar is allocated less than toolbarHeight make it // appear to scroll upwards within its shrinking container. Widget appBar = ClipRect( + clipBehavior: widget.clipBehavior ?? Clip.hardEdge, child: CustomSingleChildLayout( delegate: _ToolbarContainerLayout(toolbarHeight), child: IconTheme.merge( @@ -1186,6 +1191,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { required this.titleTextStyle, required this.systemOverlayStyle, required this.forceMaterialTransparency, + required this.clipBehavior }) : assert(primary || topPadding == 0.0), _bottomHeight = bottom?.preferredSize.height ?? 0.0; @@ -1221,6 +1227,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { final SystemUiOverlayStyle? systemOverlayStyle; final double _bottomHeight; final bool forceMaterialTransparency; + final Clip? clipBehavior; @override double get minExtent => collapsedHeight; @@ -1259,6 +1266,7 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { toolbarOpacity: toolbarOpacity, isScrolledUnder: isScrolledUnder, child: AppBar( + clipBehavior: clipBehavior, leading: leading, automaticallyImplyLeading: automaticallyImplyLeading, title: title, @@ -1463,6 +1471,7 @@ class SliverAppBar extends StatefulWidget { this.titleTextStyle, this.systemOverlayStyle, this.forceMaterialTransparency = false, + this.clipBehavior, }) : assert(floating || !snap, 'The "snap" argument only makes sense for floating app bars.'), assert(stretchTriggerOffset > 0.0), assert(collapsedHeight == null || collapsedHeight >= toolbarHeight, 'The "collapsedHeight" argument has to be larger than or equal to [toolbarHeight].'); @@ -1929,6 +1938,9 @@ class SliverAppBar extends StatefulWidget { /// This property is used to configure an [AppBar]. final bool forceMaterialTransparency; + /// {@macro flutter.material.Material.clipBehavior} + final Clip? clipBehavior; + @override State createState() => _SliverAppBarState(); } @@ -2035,6 +2047,7 @@ class _SliverAppBarState extends State with TickerProviderStateMix titleTextStyle: widget.titleTextStyle, systemOverlayStyle: widget.systemOverlayStyle, forceMaterialTransparency: widget.forceMaterialTransparency, + clipBehavior: widget.clipBehavior, ), ), ); diff --git a/packages/flutter/lib/src/material/bottom_sheet.dart b/packages/flutter/lib/src/material/bottom_sheet.dart index ee238503e81..c4d587ef483 100644 --- a/packages/flutter/lib/src/material/bottom_sheet.dart +++ b/packages/flutter/lib/src/material/bottom_sheet.dart @@ -1322,19 +1322,19 @@ class _BottomSheetDefaultsM3 extends BottomSheetThemeData { late final ColorScheme _colors = Theme.of(context).colorScheme; @override - Color get backgroundColor => _colors.surface; + Color? get backgroundColor => _colors.surface; @override - Color get surfaceTintColor => _colors.surfaceTint; + Color? get surfaceTintColor => _colors.surfaceTint; @override - Color get shadowColor => Colors.transparent; + Color? get shadowColor => Colors.transparent; @override - Color get dragHandleColor => Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.4); + Color? get dragHandleColor => _colors.onSurfaceVariant.withOpacity(0.4); @override - Size get dragHandleSize => const Size(32, 4); + Size? get dragHandleSize => const Size(32, 4); } // END GENERATED TOKEN PROPERTIES - BottomSheet diff --git a/packages/flutter/lib/src/material/search_anchor.dart b/packages/flutter/lib/src/material/search_anchor.dart index 33aeeca055b..30a5c91751d 100644 --- a/packages/flutter/lib/src/material/search_anchor.dart +++ b/packages/flutter/lib/src/material/search_anchor.dart @@ -2,20 +2,921 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; +import 'dart:ui'; + import 'package:flutter/widgets.dart'; +import 'button_style.dart'; import 'color_scheme.dart'; import 'colors.dart'; import 'constants.dart'; +import 'divider.dart'; +import 'divider_theme.dart'; +import 'icon_button.dart'; +import 'icons.dart'; import 'ink_well.dart'; import 'input_border.dart'; import 'input_decorator.dart'; import 'material.dart'; import 'material_state.dart'; import 'search_bar_theme.dart'; +import 'search_view_theme.dart'; import 'text_field.dart'; import 'text_theme.dart'; import 'theme.dart'; +import 'theme_data.dart'; + +const int _kOpenViewMilliseconds = 600; +const Duration _kOpenViewDuration = Duration(milliseconds: _kOpenViewMilliseconds); +const Duration _kAnchorFadeDuration = Duration(milliseconds: 150); +const Curve _kViewFadeOnInterval = Interval(0.0, 1/2); +const Curve _kViewIconsFadeOnInterval = Interval(1/6, 2/6); +const Curve _kViewDividerFadeOnInterval = Interval(0.0, 1/6); +const Curve _kViewListFadeOnInterval = Interval(133 / _kOpenViewMilliseconds, 233 / _kOpenViewMilliseconds); + +/// Signature for a function that creates a [Widget] which is used to open a search view. +/// +/// The `controller` callback provided to [SearchAnchor.builder] can be used +/// to open the search view and control the editable field on the view. +typedef SearchAnchorChildBuilder = Widget Function(BuildContext context, SearchController controller); + +/// Signature for a function that creates a [Widget] to build the suggestion list +/// based on the input in the search bar. +/// +/// 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); + +/// Signature for a function that creates a [Widget] to layout the suggestion list. +/// +/// Parameter `suggestions` is the content list that this function wants to lay out. +typedef ViewBuilder = Widget Function(Iterable suggestions); + +/// Manages a "search view" route that allows the user to select one of the +/// suggested completions for a search query. +/// +/// The search view's route can either be shown by creating a [SearchController] +/// and then calling [SearchController.openView] or by tapping on an anchor. +/// When the anchor is tapped or [SearchController.openView] is called, the search view either +/// grows to a specific size, or grows to fill the entire screen. By default, +/// the search view only shows full screen on mobile platforms. Use [SearchAnchor.isFullScreen] +/// to override the default setting. +/// +/// The search view is usually opened by a [SearchBar], an [IconButton] or an [Icon]. +/// If [builder] returns an Icon, or any un-tappable widgets, we don't have +/// to explicitly call [SearchController.openView]. +/// +/// {@tool dartpad} +/// This example shows how to use an IconButton to open a search view in a [SearchAnchor]. +/// It also shows how to use [SearchController] to open or close the search view route. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.2.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to set up a floating (or pinned) AppBar with a +/// [SearchAnchor] for a title. +/// +/// ** See code in examples/api/lib/material/search_anchor/search_anchor.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SearchBar], a widget that defines a search bar. +/// * [SearchBarTheme], a widget that overrides the default configuration of a search bar. +/// * [SearchViewTheme], a widget that overrides the default configuration of a search view. +class SearchAnchor extends StatefulWidget { + /// Creates a const [SearchAnchor]. + /// + /// The [builder] and [suggestionsBuilder] arguments are required. + const SearchAnchor({ + super.key, + this.isFullScreen, + this.searchController, + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.headerTextStyle, + this.headerHintStyle, + this.dividerColor, + this.viewConstraints, + required this.builder, + required this.suggestionsBuilder, + }); + + /// Create a [SearchAnchor] that has a [SearchBar] which opens a search view. + /// + /// All the barX parameters are used to customize the anchor. Similarly, all the + /// viewX parameters are used to override the view's defaults. + /// + /// {@tool dartpad} + /// This example shows how to use a [SearchAnchor.bar] which uses a default search + /// bar to open a search view route. + /// + /// ** See code in examples/api/lib/material/search_anchor/search_anchor.0.dart ** + /// {@end-tool} + /// + /// The [suggestionsBuilder] argument must not be null. + factory SearchAnchor.bar({ + Widget? barLeading, + Iterable? barTrailing, + String? barHintText, + GestureTapCallback? onTap, + MaterialStateProperty? barElevation, + MaterialStateProperty? barBackgroundColor, + MaterialStateProperty? barOverlayColor, + MaterialStateProperty? barSide, + MaterialStateProperty? barShape, + MaterialStateProperty? barPadding, + MaterialStateProperty? barTextStyle, + MaterialStateProperty? barHintStyle, + Widget? viewLeading, + Iterable? viewTrailing, + String? viewHintText, + Color? viewBackgroundColor, + double? viewElevation, + BorderSide? viewSide, + OutlinedBorder? viewShape, + TextStyle? viewHeaderTextStyle, + TextStyle? viewHeaderHintStyle, + Color? dividerColor, + BoxConstraints? constraints, + bool? isFullScreen, + SearchController searchController, + required SuggestionsBuilder suggestionsBuilder + }) = _SearchAnchorWithSearchBar; + + /// Whether the search view grows to fill the entire screen when the + /// [SearchAnchor] is tapped. + /// + /// By default, the search view is full-screen on mobile devices. On other + /// platforms, the search view only grows to a specific size that is determined + /// by the anchor and the default size. + final bool? isFullScreen; + + /// An optional controller that allows opening and closing of the search view from + /// other widgets. + /// + /// If this is null, one internal search controller is created automatically + /// and it is used to open the search view when the user taps on the anchor. + final SearchController? searchController; + + /// Optional callback to obtain a widget to lay out the suggestion list of the + /// search view. + /// + /// Default view uses a [ListView] with a vertical scroll direction. + final ViewBuilder? viewBuilder; + + /// An optional widget to display before the text input field when the search + /// view is open. + /// + /// Typically the [viewLeading] widget is an [Icon] or an [IconButton]. + /// + /// Defaults to a back button which pops the view. + final Widget? viewLeading; + + /// An optional widget list to display after the text input field when the search + /// view is open. + /// + /// Typically the [viewTrailing] widget list only has one or two widgets. + /// + /// Defaults to an icon button which clears the text in the input field. + final Iterable? viewTrailing; + + /// Text that is displayed when the search bar's input field is empty. + final String? viewHintText; + + /// The search view's background fill color. + /// + /// If null, the value of [SearchViewThemeData.backgroundColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surface]. + final Color? viewBackgroundColor; + + /// The elevation of the search view's [Material]. + /// + /// If null, the value of [SearchViewThemeData.elevation] will be used. If this + /// is also null, then default value is 6.0. + final double? viewElevation; + + /// The surface tint color of the search view's [Material]. + /// + /// See [Material.surfaceTintColor] for more details. + /// + /// If null, the value of [SearchViewThemeData.surfaceTintColor] will be used. + /// If this is also null, then the default value is [ColorScheme.surfaceTint]. + final Color? viewSurfaceTintColor; + + /// The color and weight of the search view's outline. + /// + /// This value is combined with [viewShape] to create a shape decorated + /// with an outline. This will be ignored if the view is full-screen. + /// + /// If null, the value of [SearchViewThemeData.side] will be used. If this is + /// also null, the search view doesn't have a side by default. + final BorderSide? viewSide; + + /// The shape of the search view's underlying [Material]. + /// + /// This shape is combined with [viewSide] to create a shape decorated + /// with an outline. + /// + /// If null, the value of [SearchViewThemeData.shape] will be used. + /// If this is also null, then the default value is a rectangle shape for full-screen + /// mode and a [RoundedRectangleBorder] shape with a 28.0 radius otherwise. + final OutlinedBorder? viewShape; + + /// The style to use for the text being edited on the search view. + /// + /// If null, defaults to the `bodyLarge` text style from the current [Theme]. + /// The default text color is [ColorScheme.onSurface]. + final TextStyle? headerTextStyle; + + /// The style to use for the [viewHintText] on the search view. + /// + /// If null, the value of [SearchViewThemeData.headerHintStyle] will be used. + /// If this is also null, the value of [headerTextStyle] will be used. If this is also null, + /// defaults to the `bodyLarge` text style from the current [Theme]. The default + /// text color is [ColorScheme.onSurfaceVariant]. + final TextStyle? headerHintStyle; + + /// The color of the divider on the search view. + /// + /// If this property is null, then [SearchViewThemeData.dividerColor] is used. + /// If that is also null, the default value is [ColorScheme.outline]. + final Color? dividerColor; + + /// Optional size constraints for the search view. + /// + /// If null, the value of [SearchViewThemeData.constraints] will be used. If + /// this is also null, then the constraints defaults to: + /// ```dart + /// const BoxConstraints(minWidth: 360.0, minHeight: 240.0) + /// ``` + final BoxConstraints? viewConstraints; + + /// Called to create a widget which can open a search view route when it is tapped. + /// + /// The widget returned by this builder is faded out when it is tapped. + /// At the same time a search view route is faded in. + /// + /// This must not be null. + final SearchAnchorChildBuilder builder; + + /// Called to get the suggestion list for the search view. + /// + /// By default, the list returned by this builder is laid out in a [ListView]. + /// To get a different layout, use [viewBuilder] to override. + final SuggestionsBuilder suggestionsBuilder; + + @override + State createState() => _SearchAnchorState(); +} + +class _SearchAnchorState extends State { + bool _anchorIsVisible = true; + final GlobalKey _anchorKey = GlobalKey(); + bool get _viewIsOpen => !_anchorIsVisible; + late SearchController? _internalSearchController; + SearchController get _searchController => widget.searchController ?? _internalSearchController!; + + @override + void initState() { + super.initState(); + if (widget.searchController == null) { + _internalSearchController = SearchController(); + } + _searchController._attach(this); + } + + @override + void dispose() { + super.dispose(); + _searchController._detach(this); + _internalSearchController = null; + } + + void _openView() { + Navigator.of(context).push(_SearchViewRoute( + viewLeading: widget.viewLeading, + viewTrailing: widget.viewTrailing, + viewHintText: widget.viewHintText, + viewBackgroundColor: widget.viewBackgroundColor, + viewElevation: widget.viewElevation, + viewSurfaceTintColor: widget.viewSurfaceTintColor, + viewSide: widget.viewSide, + viewShape: widget.viewShape, + viewHeaderTextStyle: widget.headerTextStyle, + viewHeaderHintStyle: widget.headerHintStyle, + dividerColor: widget.dividerColor, + viewConstraints: widget.viewConstraints, + showFullScreenView: getShowFullScreenView(), + toggleVisibility: toggleVisibility, + textDirection: Directionality.of(context), + viewBuilder: widget.viewBuilder, + anchorKey: _anchorKey, + searchController: _searchController, + suggestionsBuilder: widget.suggestionsBuilder, + )); + } + + void _closeView(String? selectedText) { + if (selectedText != null) { + _searchController.text = selectedText; + } + Navigator.of(context).pop(); + } + + Rect? getRect(GlobalKey key) { + final BuildContext? context = key.currentContext; + if (context != null) { + final RenderBox searchBarBox = context.findRenderObject()! as RenderBox; + final Size boxSize = searchBarBox.size; + final Offset boxLocation = searchBarBox.localToGlobal(Offset.zero); + return boxLocation & boxSize; + } + return null; + } + + bool toggleVisibility() { + setState(() { + _anchorIsVisible = !_anchorIsVisible; + }); + return _anchorIsVisible; + } + + bool getShowFullScreenView() { + if (widget.isFullScreen != null) { + return widget.isFullScreen!; + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return true; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + } + } + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + key: _anchorKey, + opacity: _anchorIsVisible ? 1.0 : 0.0, + duration: _kAnchorFadeDuration, + child: GestureDetector( + onTap: _openView, + child: widget.builder(context, _searchController), + ), + ); + } +} + +class _SearchViewRoute extends PopupRoute<_SearchViewRoute> { + _SearchViewRoute({ + this.toggleVisibility, + this.textDirection, + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.viewHeaderTextStyle, + this.viewHeaderHintStyle, + this.dividerColor, + this.viewConstraints, + required this.showFullScreenView, + required this.anchorKey, + required this.searchController, + required this.suggestionsBuilder, + }); + + final ValueGetter? toggleVisibility; + final TextDirection? textDirection; + final ViewBuilder? viewBuilder; + final Widget? viewLeading; + final Iterable? viewTrailing; + final String? viewHintText; + final Color? viewBackgroundColor; + final double? viewElevation; + final Color? viewSurfaceTintColor; + final BorderSide? viewSide; + final OutlinedBorder? viewShape; + final TextStyle? viewHeaderTextStyle; + final TextStyle? viewHeaderHintStyle; + final Color? dividerColor; + final BoxConstraints? viewConstraints; + final bool showFullScreenView; + final GlobalKey anchorKey; + final SearchController searchController; + final SuggestionsBuilder suggestionsBuilder; + + @override + Color? get barrierColor => Colors.transparent; + + @override + bool get barrierDismissible => true; + + @override + String? get barrierLabel => 'Dismiss'; + + late final SearchViewThemeData viewDefaults; + late final SearchViewThemeData viewTheme; + late final DividerThemeData dividerTheme; + final RectTween _rectTween = RectTween(); + + Rect? getRect() { + final BuildContext? context = anchorKey.currentContext; + if (context != null) { + final RenderBox searchBarBox = context.findRenderObject()! as RenderBox; + final Size boxSize = searchBarBox.size; + final Offset boxLocation = searchBarBox.localToGlobal(Offset.zero); + return boxLocation & boxSize; + } + return null; + } + + @override + TickerFuture didPush() { + assert(anchorKey.currentContext != null); + updateViewConfig(anchorKey.currentContext!); + updateTweens(anchorKey.currentContext!); + toggleVisibility?.call(); + return super.didPush(); + } + + @override + bool didPop(_SearchViewRoute? result) { + assert(anchorKey.currentContext != null); + updateTweens(anchorKey.currentContext!); + toggleVisibility?.call(); + return super.didPop(result); + } + + void updateViewConfig(BuildContext context) { + viewDefaults = _SearchViewDefaultsM3(context, isFullScreen: showFullScreenView); + viewTheme = SearchViewTheme.of(context); + dividerTheme = DividerTheme.of(context); + } + + void updateTweens(BuildContext context) { + final Size screenSize = MediaQuery.of(context).size; + final Rect anchorRect = getRect() ?? Rect.zero; + + // Check if the search view goes off the screen. + final BoxConstraints effectiveConstraints = viewConstraints ?? viewTheme.constraints ?? viewDefaults.constraints!; + final double verticalDistanceToEdge = screenSize.height - anchorRect.top; + final double endHeight = math.max(effectiveConstraints.minHeight, math.min(screenSize.height * 2 / 3, verticalDistanceToEdge)); + _rectTween.begin = anchorRect; + + switch (textDirection ?? TextDirection.ltr) { + case TextDirection.ltr: + final double viewEdgeToScreenEdge = screenSize.width - anchorRect.left; + final double endWidth = math.max(effectiveConstraints.minWidth, math.min(anchorRect.width, viewEdgeToScreenEdge)); + final Size endSize = Size(endWidth, endHeight); + _rectTween.end = showFullScreenView ? Offset.zero & screenSize : (anchorRect.topLeft & endSize); + return; + case TextDirection.rtl: + final double viewEdgeToScreenEdge = anchorRect.right; + final double endWidth = math.max(effectiveConstraints.minWidth, math.min(anchorRect.width, viewEdgeToScreenEdge)); + final Offset topLeft = Offset(math.max(anchorRect.right - endWidth, 0.0), anchorRect.top); + final Size endSize = Size(endWidth, endHeight); + _rectTween.end = showFullScreenView ? Offset.zero & screenSize : (topLeft & endSize); + } + } + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + + return Directionality( + textDirection: textDirection ?? TextDirection.ltr, + child: AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final Animation curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeInOutCubicEmphasized, + reverseCurve: Curves.easeInOutCubicEmphasized.flipped, + ); + + final Rect viewRect = _rectTween.evaluate(curvedAnimation)!; + final double topPadding = showFullScreenView + ? lerpDouble(0.0, MediaQuery.paddingOf(context).top, curvedAnimation.value)! + : 0.0; + + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: _kViewFadeOnInterval, + reverseCurve: _kViewFadeOnInterval.flipped, + ), + child: _ViewContent( + viewLeading: viewLeading, + viewTrailing: viewTrailing, + viewHintText: viewHintText, + viewBackgroundColor: viewBackgroundColor, + viewElevation: viewElevation, + viewSurfaceTintColor: viewSurfaceTintColor, + viewSide: viewSide, + viewShape: viewShape, + viewHeaderTextStyle: viewHeaderTextStyle, + viewHeaderHintStyle: viewHeaderHintStyle, + dividerColor: dividerColor, + viewConstraints: viewConstraints, + showFullScreenView: showFullScreenView, + animation: curvedAnimation, + getRect: getRect, + topPadding: topPadding, + viewRect: viewRect, + viewDefaults: viewDefaults, + viewTheme: viewTheme, + dividerTheme: dividerTheme, + viewBuilder: viewBuilder, + searchController: searchController, + suggestionsBuilder: suggestionsBuilder, + ), + ); + } + ), + ); + } + + @override + Duration get transitionDuration => _kOpenViewDuration; +} + +class _ViewContent extends StatefulWidget { + const _ViewContent({ + this.viewBuilder, + this.viewLeading, + this.viewTrailing, + this.viewHintText, + this.viewBackgroundColor, + this.viewElevation, + this.viewSurfaceTintColor, + this.viewSide, + this.viewShape, + this.viewHeaderTextStyle, + this.viewHeaderHintStyle, + this.dividerColor, + this.viewConstraints, + required this.showFullScreenView, + required this.getRect, + required this.topPadding, + required this.animation, + required this.viewRect, + required this.viewDefaults, + required this.viewTheme, + required this.dividerTheme, + required this.searchController, + required this.suggestionsBuilder, + }); + + final ViewBuilder? viewBuilder; + final Widget? viewLeading; + final Iterable? viewTrailing; + final String? viewHintText; + final Color? viewBackgroundColor; + final double? viewElevation; + final Color? viewSurfaceTintColor; + final BorderSide? viewSide; + final OutlinedBorder? viewShape; + final TextStyle? viewHeaderTextStyle; + final TextStyle? viewHeaderHintStyle; + final Color? dividerColor; + final BoxConstraints? viewConstraints; + final bool showFullScreenView; + final ValueGetter getRect; + final double topPadding; + final Animation animation; + final Rect viewRect; + final SearchViewThemeData viewDefaults; + final SearchViewThemeData viewTheme; + final DividerThemeData dividerTheme; + final SearchController searchController; + final SuggestionsBuilder suggestionsBuilder; + + @override + State<_ViewContent> createState() => _ViewContentState(); +} + +class _ViewContentState extends State<_ViewContent> { + Size? _screenSize; + late Rect _viewRect; + late final SearchController _controller; + late Iterable result; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _viewRect = widget.viewRect; + _controller = widget.searchController; + result = widget.suggestionsBuilder(context, _controller); + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + } + + @override + void didUpdateWidget(covariant _ViewContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.viewRect != oldWidget.viewRect) { + setState(() { + _viewRect = widget.viewRect; + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final Size updatedScreenSize = MediaQuery.of(context).size; + if (_screenSize != updatedScreenSize) { + _screenSize = updatedScreenSize; + setState(() { + final Rect anchorRect = widget.getRect() ?? _viewRect; + final BoxConstraints constraints = widget.viewConstraints ?? widget.viewTheme.constraints ?? widget.viewDefaults.constraints!; + final Size updatedViewSize = Size(math.max(constraints.minWidth, anchorRect.width), _viewRect.height); + switch (Directionality.of(context)) { + case TextDirection.ltr: + final Offset updatedPosition = anchorRect.topLeft; + _viewRect = updatedPosition & updatedViewSize; + return; + case TextDirection.rtl: + final Offset topLeft = Offset(math.max(anchorRect.right - updatedViewSize.width, 0.0), anchorRect.top); + _viewRect = topLeft & updatedViewSize; + } + }); + } + } + + Widget viewBuilder(Iterable suggestions) { + if (widget.viewBuilder == null) { + return MediaQuery.removePadding( + context: context, + removeTop: true, + child: ListView( + children: suggestions.toList() + ), + ); + } + return widget.viewBuilder!(suggestions); + } + + void updateSuggestions() { + setState(() { + result = widget.suggestionsBuilder(context, _controller); + }); + } + + @override + Widget build(BuildContext context) { + final Widget defaultLeading = IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { Navigator.of(context).pop(); }, + style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + ); + + final List defaultTrailing = [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _controller.clear(); + updateSuggestions(); + }, + ), + ]; + + final Color effectiveBackgroundColor = widget.viewBackgroundColor + ?? widget.viewTheme.backgroundColor + ?? widget.viewDefaults.backgroundColor!; + final Color effectiveSurfaceTint = widget.viewSurfaceTintColor + ?? widget.viewTheme.surfaceTintColor + ?? widget.viewDefaults.surfaceTintColor!; + final double effectiveElevation = widget.viewElevation + ?? widget.viewTheme.elevation + ?? widget.viewDefaults.elevation!; + final BorderSide? effectiveSide = widget.viewSide + ?? widget.viewTheme.side + ?? widget.viewDefaults.side; + OutlinedBorder effectiveShape = widget.viewShape + ?? widget.viewTheme.shape + ?? widget.viewDefaults.shape!; + if (effectiveSide != null) { + effectiveShape = effectiveShape.copyWith(side: effectiveSide); + } + final Color effectiveDividerColor = widget.dividerColor + ?? widget.viewTheme.dividerColor + ?? widget.dividerTheme.color + ?? widget.viewDefaults.dividerColor!; + final TextStyle? effectiveTextStyle = widget.viewHeaderTextStyle + ?? widget.viewTheme.headerTextStyle + ?? widget.viewDefaults.headerTextStyle; + final TextStyle? effectiveHintStyle = widget.viewHeaderHintStyle + ?? widget.viewTheme.headerHintStyle + ?? widget.viewHeaderTextStyle + ?? widget.viewTheme.headerTextStyle + ?? widget.viewDefaults.headerHintStyle; + + final Widget viewDivider = DividerTheme( + data: widget.dividerTheme.copyWith(color: effectiveDividerColor), + child: const Divider(height: 1), + ); + + return Align( + alignment: Alignment.topLeft, + child: Transform.translate( + offset: _viewRect.topLeft, + child: SizedBox( + width: _viewRect.width, + height: _viewRect.height, + child: Material( + shape: effectiveShape, + color: effectiveBackgroundColor, + surfaceTintColor: effectiveSurfaceTint, + elevation: effectiveElevation, + child: FadeTransition( + opacity: CurvedAnimation( + parent: widget.animation, + curve: _kViewIconsFadeOnInterval, + reverseCurve: _kViewIconsFadeOnInterval.flipped, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.only(top: widget.topPadding), + child: SafeArea( + top: false, + bottom: false, + child: SearchBar( + constraints: widget.showFullScreenView ? BoxConstraints(minHeight: _SearchViewDefaultsM3.fullScreenBarHeight) : null, + focusNode: _focusNode, + leading: widget.viewLeading ?? defaultLeading, + trailing: widget.viewTrailing ?? defaultTrailing, + hintText: widget.viewHintText, + backgroundColor: const MaterialStatePropertyAll(Colors.transparent), + overlayColor: const MaterialStatePropertyAll(Colors.transparent), + elevation: const MaterialStatePropertyAll(0.0), + textStyle: MaterialStatePropertyAll(effectiveTextStyle), + hintStyle: MaterialStatePropertyAll(effectiveHintStyle), + controller: _controller, + onChanged: (_) { + updateSuggestions(); + }, + ), + ), + ), + FadeTransition( + opacity: CurvedAnimation( + parent: widget.animation, + curve: _kViewDividerFadeOnInterval, + reverseCurve: _kViewFadeOnInterval.flipped, + ), + child: viewDivider), + Expanded( + child: FadeTransition( + opacity: CurvedAnimation( + parent: widget.animation, + curve: _kViewListFadeOnInterval, + reverseCurve: _kViewListFadeOnInterval.flipped, + ), + child: viewBuilder(result), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _SearchAnchorWithSearchBar extends SearchAnchor { + _SearchAnchorWithSearchBar({ + Widget? barLeading, + Iterable? barTrailing, + String? barHintText, + GestureTapCallback? onTap, + MaterialStateProperty? barElevation, + MaterialStateProperty? barBackgroundColor, + MaterialStateProperty? barOverlayColor, + MaterialStateProperty? barSide, + MaterialStateProperty? barShape, + MaterialStateProperty? barPadding, + MaterialStateProperty? barTextStyle, + MaterialStateProperty? barHintStyle, + super.viewLeading, + super.viewTrailing, + String? viewHintText, + super.viewBackgroundColor, + super.viewElevation, + super.viewSide, + super.viewShape, + TextStyle? viewHeaderTextStyle, + TextStyle? viewHeaderHintStyle, + super.dividerColor, + BoxConstraints? constraints, + super.isFullScreen, + super.searchController, + required super.suggestionsBuilder + }) : super( + viewHintText: viewHintText ?? barHintText, + headerTextStyle: viewHeaderTextStyle, + headerHintStyle: viewHeaderHintStyle, + builder: (BuildContext context, SearchController controller) { + return SearchBar( + constraints: constraints, + controller: controller, + onTap: () { + controller.openView(); + onTap?.call(); + }, + onChanged: (_) { + controller.openView(); + }, + hintText: barHintText, + hintStyle: barHintStyle, + textStyle: barTextStyle, + elevation: barElevation, + backgroundColor: barBackgroundColor, + overlayColor: barOverlayColor, + side: barSide, + shape: barShape, + padding: barPadding ?? const MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 16.0)), + leading: barLeading ?? const Icon(Icons.search), + trailing: barTrailing, + ); + } + ); +} + +/// A controller to manage a search view created by [SearchAnchor]. +/// +/// A [SearchController] is used to control a menu after it has been created, +/// with methods such as [openView] and [closeView]. It can also control the text in the +/// input field. +/// +/// See also: +/// +/// * [SearchAnchor], a widget that defines a region that opens a search view. +/// * [TextEditingController], A controller for an editable text field. +class SearchController extends TextEditingController { + // The anchor that this controller controls. + // + // This is set automatically when a [SearchController] is given to the anchor + // it controls. + _SearchAnchorState? _anchor; + + /// Whether or not the associated search view is currently open. + bool get isOpen { + assert(_anchor != null); + return _anchor!._viewIsOpen; + } + + /// Opens the search view that this controller is associated with. + void openView() { + assert(_anchor != null); + _anchor!._openView(); + } + + /// Close the search view that this search controller is associated with. + /// + /// If `selectedText` is given, then the text value of the controller is set to + /// `selectedText`. + void closeView(String? selectedText) { + assert(_anchor != null); + _anchor!._closeView(selectedText); + } + + // ignore: use_setters_to_change_properties + void _attach(_SearchAnchorState anchor) { + _anchor = anchor; + } + + void _detach(_SearchAnchorState anchor) { + if (_anchor == anchor) { + _anchor = null; + } + } +} /// A Material Design search bar. /// @@ -102,7 +1003,7 @@ class SearchBar extends StatefulWidget { /// The elevation of the search bar's [Material]. /// /// If null, the value of [SearchBarThemeData.elevation] will be used. If this - /// is also null, then default value is 8.0. + /// is also null, then default value is 6.0. final MaterialStateProperty? elevation; /// The search bar's background fill color. @@ -144,8 +1045,7 @@ class SearchBar extends StatefulWidget { /// with an outline. /// /// If null, the value of [SearchBarThemeData.shape] will be used. - /// If this is also null, then the default value is 16.0 horizontally. - /// Defaults to [StadiumBorder]. + /// If this is also null, defaults to [StadiumBorder]. final MaterialStateProperty? shape; /// The padding between the search bar's boundary and its contents. @@ -254,50 +1154,48 @@ class _SearchBarState extends State { )).toList(); } - return SafeArea( - child: ConstrainedBox( - constraints: widget.constraints ?? searchBarTheme.constraints ?? defaults.constraints!, - child: Material( - elevation: effectiveElevation!, - shadowColor: effectiveShadowColor, - color: effectiveBackgroundColor, - surfaceTintColor: effectiveSurfaceTintColor, - shape: effectiveShape?.copyWith(side: effectiveSide), - child: InkWell( - onTap: () { - widget.onTap?.call(); - _focusNode.requestFocus(); - }, - overlayColor: effectiveOverlayColor, - customBorder: effectiveShape?.copyWith(side: effectiveSide), - statesController: _internalStatesController, - child: Padding( - padding: effectivePadding!, - child: Row( - textDirection: textDirection, - children: [ - if (leading != null) leading, - Expanded( - child: IgnorePointer( - child: Padding( - padding: effectivePadding, - child: TextField( - focusNode: _focusNode, - onChanged: widget.onChanged, - controller: widget.controller, - style: effectiveTextStyle, - decoration: InputDecoration( - border: InputBorder.none, - hintText: widget.hintText, - hintStyle: effectiveHintStyle, - ), + return ConstrainedBox( + constraints: widget.constraints ?? searchBarTheme.constraints ?? defaults.constraints!, + child: Material( + elevation: effectiveElevation!, + shadowColor: effectiveShadowColor, + color: effectiveBackgroundColor, + surfaceTintColor: effectiveSurfaceTintColor, + shape: effectiveShape?.copyWith(side: effectiveSide), + child: InkWell( + onTap: () { + widget.onTap?.call(); + _focusNode.requestFocus(); + }, + overlayColor: effectiveOverlayColor, + customBorder: effectiveShape?.copyWith(side: effectiveSide), + statesController: _internalStatesController, + child: Padding( + padding: effectivePadding!, + child: Row( + textDirection: textDirection, + children: [ + if (leading != null) leading, + Expanded( + child: IgnorePointer( + child: Padding( + padding: effectivePadding, + child: TextField( + focusNode: _focusNode, + onChanged: widget.onChanged, + controller: widget.controller, + style: effectiveTextStyle, + decoration: InputDecoration( + border: InputBorder.none, + hintText: widget.hintText, + hintStyle: effectiveHintStyle, ), ), - ) - ), - if (trailing != null) ...trailing, - ], - ), + ), + ) + ), + if (trailing != null) ...trailing, + ], ), ), ), @@ -313,7 +1211,7 @@ class _SearchBarState extends State { // Design token database by the script: // dev/tools/gen_defaults/bin/gen_defaults.dart. -// Token database version: v0_158 +// Token database version: v0_162 class _SearchBarDefaultsM3 extends SearchBarThemeData { _SearchBarDefaultsM3(this.context); @@ -377,3 +1275,53 @@ class _SearchBarDefaultsM3 extends SearchBarThemeData { } // END GENERATED TOKEN PROPERTIES - SearchBar + +// BEGIN GENERATED TOKEN PROPERTIES - SearchView + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_162 + +class _SearchViewDefaultsM3 extends SearchViewThemeData { + _SearchViewDefaultsM3(this.context, {required this.isFullScreen}); + + final BuildContext context; + final bool isFullScreen; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + static double fullScreenBarHeight = 72.0; + + @override + Color? get backgroundColor => _colors.surface; + + @override + double? get elevation => 6.0; + + @override + Color? get surfaceTintColor => _colors.surfaceTint; + + // No default side + + @override + OutlinedBorder? get shape => isFullScreen + ? const RoundedRectangleBorder() + : const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(28.0))); + + @override + TextStyle? get headerTextStyle => _textTheme.bodyLarge?.copyWith(color: _colors.onSurface); + + @override + TextStyle? get headerHintStyle => _textTheme.bodyLarge?.copyWith(color: _colors.onSurfaceVariant); + + @override + BoxConstraints get constraints => const BoxConstraints(minWidth: 360.0, minHeight: 240.0); + + @override + Color? get dividerColor => _colors.outline; +} + +// END GENERATED TOKEN PROPERTIES - SearchView diff --git a/packages/flutter/lib/src/material/search_view_theme.dart b/packages/flutter/lib/src/material/search_view_theme.dart new file mode 100644 index 00000000000..ec893000c2e --- /dev/null +++ b/packages/flutter/lib/src/material/search_view_theme.dart @@ -0,0 +1,217 @@ +// 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:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// Defines the configuration of the search views created by the [SearchAnchor] +/// widget. +/// +/// Descendant widgets obtain the current [SearchViewThemeData] object using +/// `SearchViewTheme.of(context)`. +/// +/// Typically, a [SearchViewThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.searchViewTheme]. Otherwise, [SearchViewTheme] can be used +/// to configure its own widget subtree. +/// +/// All [SearchViewThemeData] properties are `null` by default. If any of these +/// properties are null, the search view will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme for the application. +/// * [SearchBarThemeData], which describes the theme for the search bar itself in a +/// [SearchBar] widget. +/// * [SearchAnchor], which is used to open a search view route. +@immutable +class SearchViewThemeData with Diagnosticable { + /// Creates a theme that can be used for [ThemeData.searchViewTheme]. + const SearchViewThemeData({ + this.backgroundColor, + this.elevation, + this.surfaceTintColor, + this.constraints, + this.side, + this.shape, + this.headerTextStyle, + this.headerHintStyle, + this.dividerColor, + }); + + /// Overrides the default value of the [SearchAnchor.viewBackgroundColor]. + final Color? backgroundColor; + + /// Overrides the default value of the [SearchAnchor.viewElevation]. + final double? elevation; + + /// Overrides the default value of the [SearchAnchor.viewSurfaceTintColor]. + final Color? surfaceTintColor; + + /// Overrides the default value of the [SearchAnchor.viewSide]. + final BorderSide? side; + + /// Overrides the default value of the [SearchAnchor.viewShape]. + final OutlinedBorder? shape; + + /// Overrides the default value for [SearchAnchor.headerTextStyle]. + final TextStyle? headerTextStyle; + + /// Overrides the default value for [SearchAnchor.headerHintStyle]. + final TextStyle? headerHintStyle; + + /// Overrides the value of size constraints for [SearchAnchor.viewConstraints]. + final BoxConstraints? constraints; + + /// Overrides the value of the divider color for [SearchAnchor.dividerColor]. + final Color? dividerColor; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + SearchViewThemeData copyWith({ + Color? backgroundColor, + double? elevation, + Color? surfaceTintColor, + BorderSide? side, + OutlinedBorder? shape, + TextStyle? headerTextStyle, + TextStyle? headerHintStyle, + BoxConstraints? constraints, + Color? dividerColor, + }) { + return SearchViewThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + elevation: elevation ?? this.elevation, + surfaceTintColor: surfaceTintColor ?? this.surfaceTintColor, + side: side ?? this.side, + shape: shape ?? this.shape, + headerTextStyle: headerTextStyle ?? this.headerTextStyle, + headerHintStyle: headerHintStyle ?? this.headerHintStyle, + constraints: constraints ?? this.constraints, + dividerColor: dividerColor ?? this.dividerColor, + ); + } + + /// Linearly interpolate between two [SearchViewThemeData]s. + static SearchViewThemeData? lerp(SearchViewThemeData? a, SearchViewThemeData? b, double t) { + if (identical(a, b)) { + return a; + } + return SearchViewThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + surfaceTintColor: Color.lerp(a?.surfaceTintColor, b?.surfaceTintColor, t), + side: _lerpSides(a?.side, b?.side, t), + shape: OutlinedBorder.lerp(a?.shape, b?.shape, t), + headerTextStyle: TextStyle.lerp(a?.headerTextStyle, b?.headerTextStyle, t), + headerHintStyle: TextStyle.lerp(a?.headerTextStyle, b?.headerTextStyle, t), + constraints: BoxConstraints.lerp(a?.constraints, b?.constraints, t), + dividerColor: Color.lerp(a?.dividerColor, b?.dividerColor, t), + ); + } + + @override + int get hashCode => Object.hash( + backgroundColor, + elevation, + surfaceTintColor, + side, + shape, + headerTextStyle, + headerHintStyle, + constraints, + dividerColor, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SearchViewThemeData + && other.backgroundColor == backgroundColor + && other.elevation == elevation + && other.surfaceTintColor == surfaceTintColor + && other.side == side + && other.shape == shape + && other.headerTextStyle == headerTextStyle + && other.headerHintStyle == headerHintStyle + && other.constraints == constraints + && other.dividerColor == dividerColor; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DiagnosticsProperty('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty('surfaceTintColor', surfaceTintColor, defaultValue: null)); + properties.add(DiagnosticsProperty('side', side, defaultValue: null)); + properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty('headerTextStyle', headerTextStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('headerHintStyle', headerHintStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('constraints', constraints, defaultValue: null)); + properties.add(DiagnosticsProperty('dividerColor', dividerColor, defaultValue: null)); + } + + // Special case because BorderSide.lerp() doesn't support null arguments + static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { + if (a == null || b == null) { + return null; + } + if (identical(a, b)) { + return a; + } + return BorderSide.lerp(a, b, t); + } +} + +/// An inherited widget that defines the configuration in this widget's +/// descendants for search view created by the [SearchAnchor] widget. +/// +/// A search view theme can be specified as part of the overall Material theme using +/// [ThemeData.searchViewTheme]. +/// +/// See also: +/// +/// * [SearchViewThemeData], which describes the actual configuration of a search view +/// theme. +class SearchViewTheme extends InheritedWidget { + /// Creates a const theme that controls the configurations for the search view + /// created by the [SearchAnchor] widget. + const SearchViewTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The properties used for all descendant [SearchAnchor] widgets. + final SearchViewThemeData data; + + /// Returns the configuration [data] from the closest [SearchViewTheme] ancestor. + /// If there is no ancestor, it returns [ThemeData.searchViewTheme]. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// SearchViewThemeData theme = SearchViewTheme.of(context); + /// ``` + static SearchViewThemeData of(BuildContext context) { + final SearchViewTheme? searchViewTheme = context.dependOnInheritedWidgetOfExactType(); + return searchViewTheme?.data ?? Theme.of(context).searchViewTheme; + } + + @override + bool updateShouldNotify(SearchViewTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index a7b085b5842..ee673c61ba7 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -54,6 +54,7 @@ import 'progress_indicator_theme.dart'; import 'radio_theme.dart'; import 'scrollbar_theme.dart'; import 'search_bar_theme.dart'; +import 'search_view_theme.dart'; import 'segmented_button_theme.dart'; import 'slider_theme.dart'; import 'snack_bar_theme.dart'; @@ -375,6 +376,7 @@ class ThemeData with Diagnosticable { ProgressIndicatorThemeData? progressIndicatorTheme, RadioThemeData? radioTheme, SearchBarThemeData? searchBarTheme, + SearchViewThemeData? searchViewTheme, SegmentedButtonThemeData? segmentedButtonTheme, SliderThemeData? sliderTheme, SnackBarThemeData? snackBarTheme, @@ -590,6 +592,7 @@ class ThemeData with Diagnosticable { progressIndicatorTheme ??= const ProgressIndicatorThemeData(); radioTheme ??= const RadioThemeData(); searchBarTheme ??= const SearchBarThemeData(); + searchViewTheme ??= const SearchViewThemeData(); segmentedButtonTheme ??= const SegmentedButtonThemeData(); sliderTheme ??= const SliderThemeData(); snackBarTheme ??= const SnackBarThemeData(); @@ -688,6 +691,7 @@ class ThemeData with Diagnosticable { progressIndicatorTheme: progressIndicatorTheme, radioTheme: radioTheme, searchBarTheme: searchBarTheme, + searchViewTheme: searchViewTheme, segmentedButtonTheme: segmentedButtonTheme, sliderTheme: sliderTheme, snackBarTheme: snackBarTheme, @@ -800,6 +804,7 @@ class ThemeData with Diagnosticable { required this.progressIndicatorTheme, required this.radioTheme, required this.searchBarTheme, + required this.searchViewTheme, required this.segmentedButtonTheme, required this.sliderTheme, required this.snackBarTheme, @@ -1492,6 +1497,9 @@ class ThemeData with Diagnosticable { /// A theme for customizing the appearance and layout of [SearchBar] widgets. final SearchBarThemeData searchBarTheme; + /// A theme for customizing the appearance and layout of search views created by [SearchAnchor] widgets. + final SearchViewThemeData searchViewTheme; + /// A theme for customizing the appearance and layout of [SegmentedButton] widgets. final SegmentedButtonThemeData segmentedButtonTheme; @@ -1704,6 +1712,7 @@ class ThemeData with Diagnosticable { ProgressIndicatorThemeData? progressIndicatorTheme, RadioThemeData? radioTheme, SearchBarThemeData? searchBarTheme, + SearchViewThemeData? searchViewTheme, SegmentedButtonThemeData? segmentedButtonTheme, SliderThemeData? sliderTheme, SnackBarThemeData? snackBarTheme, @@ -1839,6 +1848,7 @@ class ThemeData with Diagnosticable { progressIndicatorTheme: progressIndicatorTheme ?? this.progressIndicatorTheme, radioTheme: radioTheme ?? this.radioTheme, searchBarTheme: searchBarTheme ?? this.searchBarTheme, + searchViewTheme: searchViewTheme ?? this.searchViewTheme, segmentedButtonTheme: segmentedButtonTheme ?? this.segmentedButtonTheme, sliderTheme: sliderTheme ?? this.sliderTheme, snackBarTheme: snackBarTheme ?? this.snackBarTheme, @@ -2034,6 +2044,7 @@ class ThemeData with Diagnosticable { progressIndicatorTheme: ProgressIndicatorThemeData.lerp(a.progressIndicatorTheme, b.progressIndicatorTheme, t)!, radioTheme: RadioThemeData.lerp(a.radioTheme, b.radioTheme, t), searchBarTheme: SearchBarThemeData.lerp(a.searchBarTheme, b.searchBarTheme, t)!, + searchViewTheme: SearchViewThemeData.lerp(a.searchViewTheme, b.searchViewTheme, t)!, segmentedButtonTheme: SegmentedButtonThemeData.lerp(a.segmentedButtonTheme, b.segmentedButtonTheme, t), sliderTheme: SliderThemeData.lerp(a.sliderTheme, b.sliderTheme, t), snackBarTheme: SnackBarThemeData.lerp(a.snackBarTheme, b.snackBarTheme, t), @@ -2141,6 +2152,7 @@ class ThemeData with Diagnosticable { other.progressIndicatorTheme == progressIndicatorTheme && other.radioTheme == radioTheme && other.searchBarTheme == searchBarTheme && + other.searchViewTheme == searchViewTheme && other.segmentedButtonTheme == segmentedButtonTheme && other.sliderTheme == sliderTheme && other.snackBarTheme == snackBarTheme && @@ -2245,6 +2257,7 @@ class ThemeData with Diagnosticable { progressIndicatorTheme, radioTheme, searchBarTheme, + searchViewTheme, segmentedButtonTheme, sliderTheme, snackBarTheme, @@ -2351,6 +2364,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('progressIndicatorTheme', progressIndicatorTheme, defaultValue: defaultData.progressIndicatorTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('radioTheme', radioTheme, defaultValue: defaultData.radioTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('searchBarTheme', searchBarTheme, defaultValue: defaultData.searchBarTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('searchViewTheme', searchViewTheme, defaultValue: defaultData.searchViewTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('segmentedButtonTheme', segmentedButtonTheme, defaultValue: defaultData.segmentedButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('sliderTheme', sliderTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('snackBarTheme', snackBarTheme, defaultValue: defaultData.snackBarTheme, level: DiagnosticLevel.debug)); diff --git a/packages/flutter/test/material/search_anchor_test.dart b/packages/flutter/test/material/search_anchor_test.dart index 47af12e178d..3ee06225799 100644 --- a/packages/flutter/test/material/search_anchor_test.dart +++ b/packages/flutter/test/material/search_anchor_test.dart @@ -723,6 +723,706 @@ void main() { await tester.pump(); expect(helperText.style?.color, hoveredColor); }); + + testWidgets('The search view defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + final ColorScheme colorScheme = theme.colorScheme; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final Material material = getSearchViewMaterial(tester); + expect(material.elevation, 6.0); + expect(material.color, colorScheme.surface); + expect(material.surfaceTintColor, colorScheme.surfaceTint); + + final Finder findDivider = find.byType(Divider); + final Container dividerContainer = tester.widget(find.descendant(of: findDivider, matching: find.byType(Container)).first); + final BoxDecoration decoration = dividerContainer.decoration! as BoxDecoration; + expect(decoration.border!.bottom.color, colorScheme.outline); + + // Default search view has a leading back button on the start of the header. + expect(find.widgetWithIcon(IconButton, Icons.arrow_back), findsOneWidget); + + // Default search view has a trailing close button on the end of the header. + // It is used to clear the input in the text field. + expect(find.widgetWithIcon(IconButton, Icons.close), findsOneWidget); + + final Text helperText = tester.widget(find.text('hint text')); + expect(helperText.style?.color, colorScheme.onSurfaceVariant); + expect(helperText.style?.fontSize, 16.0); + expect(helperText.style?.fontFamily, 'Roboto'); + expect(helperText.style?.fontWeight, FontWeight.w400); + + const String input = 'entered text'; + await tester.enterText(find.byType(SearchBar), input); + final EditableText inputText = tester.widget(find.text(input)); + expect(inputText.style.color, colorScheme.onSurface); + expect(inputText.style.fontSize, 16.0); + expect(inputText.style.fontFamily, 'Roboto'); + expect(inputText.style.fontWeight, FontWeight.w400); + }); + + testWidgets('The search view default size on different platforms', (WidgetTester tester) async { + // The search view should be is full-screen on mobile platforms, + // and have a size of (360, 2/3 screen height) on other platforms + Widget buildSearchAnchor(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + body: SafeArea( + child: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor( + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ), + ), + ); + } + + for (final TargetPlatform platform in [ TargetPlatform.iOS, TargetPlatform.android, TargetPlatform.fuchsia ]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildSearchAnchor(platform)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final SizedBox sizedBox = tester.widget(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(sizedBox.width, 800.0); + expect(sizedBox.height, 600.0); + } + + for (final TargetPlatform platform in [ TargetPlatform.linux, TargetPlatform.windows ]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildSearchAnchor(platform)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final SizedBox sizedBox = tester.widget(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(sizedBox.width, 360.0); + expect(sizedBox.height, 400.0); + } + }); + + testWidgets('SearchAnchor respects isFullScreen property', (WidgetTester tester) async { + Widget buildSearchAnchor(TargetPlatform platform) { + return MaterialApp( + theme: ThemeData(platform: platform), + home: Scaffold( + body: SafeArea( + child: Material( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor( + isFullScreen: true, + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ), + ), + ); + } + + for (final TargetPlatform platform in [ TargetPlatform.linux, TargetPlatform.windows ]) { + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildSearchAnchor(platform)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + final SizedBox sizedBox = tester.widget(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(sizedBox.width, 800.0); + expect(sizedBox.height, 600.0); + } + }); + + testWidgets('SearchAnchor respects controller property', (WidgetTester tester) async { + const String defaultText = 'initial text'; + final SearchController controller = SearchController(); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + searchController: controller, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + const String updatedText = 'updated text'; + await tester.enterText(find.byType(SearchBar), updatedText); + expect(controller.value.text, updatedText); + expect(find.text(defaultText), findsNothing); + expect(find.text(updatedText), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewBuilder property', (WidgetTester tester) async { + Widget buildAnchor({ViewBuilder? viewBuilder}) { + return MaterialApp( + home: Material( + child: SearchAnchor( + viewBuilder: viewBuilder, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor()); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + // Default is a ListView. + expect(find.byType(ListView), findsOneWidget); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAnchor(viewBuilder: (Iterable suggestions) + => GridView.count(crossAxisCount: 5, children: suggestions.toList(),) + )); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.byType(ListView), findsNothing); + expect(find.byType(GridView), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewLeading property', (WidgetTester tester) async { + Widget buildAnchor({Widget? viewLeading}) { + return MaterialApp( + home: Material( + child: SearchAnchor( + viewLeading: viewLeading, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor()); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + // Default is a icon button with arrow_back. + expect(find.widgetWithIcon(IconButton, Icons.arrow_back), findsOneWidget); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAnchor(viewLeading: const Icon(Icons.history))); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.arrow_back), findsNothing); + expect(find.byIcon(Icons.history), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewTrailing property', (WidgetTester tester) async { + Widget buildAnchor({Iterable? viewTrailing}) { + return MaterialApp( + home: Material( + child: SearchAnchor( + viewTrailing: viewTrailing, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor()); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + // Default is a icon button with close icon. + expect(find.widgetWithIcon(IconButton, Icons.close), findsOneWidget); + + await tester.pumpWidget(Container()); + await tester.pumpWidget(buildAnchor(viewTrailing: [const Icon(Icons.history)])); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.close), findsNothing); + expect(find.byIcon(Icons.history), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewHintText property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(find.text('hint text'), findsOneWidget); + }); + + testWidgets('SearchAnchor respects viewBackgroundColor property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + viewBackgroundColor: Colors.purple, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).color, Colors.purple); + }); + + testWidgets('SearchAnchor respects viewElevation property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + viewElevation: 3.0, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).elevation, 3.0); + }); + + testWidgets('SearchAnchor respects viewSurfaceTint property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + viewSurfaceTintColor: Colors.purple, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).surfaceTintColor, Colors.purple); + }); + + testWidgets('SearchAnchor respects viewSide property', (WidgetTester tester) async { + const BorderSide side = BorderSide(color: Colors.purple, width: 5.0); + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + viewSide: side, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).shape, RoundedRectangleBorder(side: side, borderRadius: BorderRadius.circular(28.0))); + }); + + testWidgets('SearchAnchor respects viewShape property', (WidgetTester tester) async { + const BorderSide side = BorderSide(color: Colors.purple, width: 5.0); + const OutlinedBorder shape = StadiumBorder(side: side); + + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + viewShape: shape, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + expect(getSearchViewMaterial(tester).shape, shape); + }); + + testWidgets('SearchAnchor respects headerTextStyle property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + headerTextStyle: theme.textTheme.bodyLarge?.copyWith(color: Colors.red), + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(SearchBar), 'input text'); + await tester.pumpAndSettle(); + + final EditableText inputText = tester.widget(find.text('input text')); + expect(inputText.style.color, Colors.red); + }); + + testWidgets('SearchAnchor respects headerHintStyle property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + viewHintText: 'hint text', + headerHintStyle: theme.textTheme.bodyLarge?.copyWith(color: Colors.orange), + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Text inputText = tester.widget(find.text('hint text')); + expect(inputText.style?.color, Colors.orange); + }); + + testWidgets('SearchAnchor respects dividerColor property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor( + dividerColor: Colors.red, + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final Finder findDivider = find.byType(Divider); + final Container dividerContainer = tester.widget(find.descendant(of: findDivider, matching: find.byType(Container)).first); + final BoxDecoration decoration = dividerContainer.decoration! as BoxDecoration; + expect(decoration.border!.bottom.color, Colors.red); + }); + + testWidgets('SearchAnchor respects viewConstraints property', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Material( + child: Center( + child: SearchAnchor( + isFullScreen: false, + viewConstraints: BoxConstraints.tight(const Size(280.0, 390.0)), + builder: (BuildContext context, SearchController controller) { + return IconButton(icon: const Icon(Icons.search), onPressed: () { + controller.openView(); + },); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + )); + + await tester.tap(find.widgetWithIcon(IconButton, Icons.search)); + await tester.pumpAndSettle(); + + final SizedBox sizedBox = tester.widget(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(sizedBox.width, 280.0); + expect(sizedBox.height, 390.0); + }); + + testWidgets('SearchAnchor respects builder property - LTR', (WidgetTester tester) async { + Widget buildAnchor({required SearchAnchorChildBuilder builder}) { + return MaterialApp( + home: Material( + child: Align( + alignment: Alignment.topCenter, + child: SearchAnchor( + isFullScreen: false, + builder: builder, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor( + builder: (BuildContext context, SearchController controller) + => const Icon(Icons.search) + )); + final Rect anchorRect = tester.getRect(find.byIcon(Icons.search)); + expect(anchorRect.size, const Size(24.0, 24.0)); + expect(anchorRect, equals(const Rect.fromLTRB(388.0, 0.0, 412.0, 24.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(searchViewRect, equals(const Rect.fromLTRB(388.0, 0.0, 748.0, 400.0))); + + // Search view top left should be the same as the anchor top left + expect(searchViewRect.topLeft, anchorRect.topLeft); + }); + + testWidgets('SearchAnchor respects builder property - RTL', (WidgetTester tester) async { + Widget buildAnchor({required SearchAnchorChildBuilder builder}) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Align( + alignment: Alignment.topCenter, + child: SearchAnchor( + isFullScreen: false, + builder: builder, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildAnchor(builder: (BuildContext context, SearchController controller) + => const Icon(Icons.search))); + final Rect anchorRect = tester.getRect(find.byIcon(Icons.search)); + expect(anchorRect.size, const Size(24.0, 24.0)); + expect(anchorRect, equals(const Rect.fromLTRB(388.0, 0.0, 412.0, 24.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(searchViewRect, equals(const Rect.fromLTRB(52.0, 0.0, 412.0, 400.0))); + + // Search view top right should be the same as the anchor top right + expect(searchViewRect.topRight, anchorRect.topRight); + }); + + testWidgets('SearchAnchor respects suggestionsBuilder property', (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) { + return [ + ListTile( + title: const Text(suggestion), + onTap: () { + setState(() { + controller.closeView(suggestion); + }); + }), + ]; + }, + ), + ), + ); + } + ), + )); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Finder listTile = find.widgetWithText(ListTile, suggestion); + expect(listTile, findsOneWidget); + await tester.tap(listTile); + 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( + child: Align( + alignment: Alignment.topLeft, + child: SearchAnchor.bar( + isFullScreen: false, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ),), + ); + + expect(find.byType(SearchBar), findsOneWidget); + final Rect anchorRect = tester.getRect(find.byType(SearchBar)); + expect(anchorRect.size, const Size(800.0, 56.0)); + expect(anchorRect, equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 56.0))); + + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + final Rect searchViewRect = tester.getRect(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(searchViewRect, equals(const Rect.fromLTRB(0.0, 0.0, 800.0, 400.0))); + + // Search view has same width with the default anchor(search bar). + expect(searchViewRect.width, anchorRect.width); + }); + + testWidgets('SearchController can open/close view', (WidgetTester tester) async { + final SearchController controller = SearchController(); + await tester.pumpWidget(MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + isFullScreen: false, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return [ + ListTile( + title: const Text('item 0'), + onTap: () { + controller.closeView('item 0'); + }, + ) + ]; + }, + ), + ),), + ); + + expect(controller.isOpen, false); + await tester.tap(find.byType(SearchBar)); + await tester.pumpAndSettle(); + + expect(controller.isOpen, true); + await tester.tap(find.widgetWithText(ListTile, 'item 0')); + await tester.pumpAndSettle(); + expect(controller.isOpen, false); + controller.openView(); + expect(controller.isOpen, true); + }); } TextStyle? _iconStyle(WidgetTester tester, IconData icon) { @@ -779,3 +1479,12 @@ Future _pointGestureToSearchBar(WidgetTester tester) async { await gesture.moveTo(center); return gesture; } +Finder findViewContent() { + return find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_ViewContent'; + }); +} + +Material getSearchViewMaterial(WidgetTester tester) { + return tester.widget(find.descendant(of: findViewContent(), matching: find.byType(Material)).first); +} diff --git a/packages/flutter/test/material/search_view_theme_test.dart b/packages/flutter/test/material/search_view_theme_test.dart new file mode 100644 index 00000000000..b85c61ef57c --- /dev/null +++ b/packages/flutter/test/material/search_view_theme_test.dart @@ -0,0 +1,245 @@ +// 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/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('SearchViewThemeData copyWith, ==, hashCode basics', () { + expect(const SearchViewThemeData(), const SearchViewThemeData().copyWith()); + expect(const SearchViewThemeData().hashCode, const SearchViewThemeData() + .copyWith() + .hashCode); + }); + + test('SearchViewThemeData lerp special cases', () { + expect(SearchViewThemeData.lerp(null, null, 0), null); + const SearchViewThemeData data = SearchViewThemeData(); + expect(identical(SearchViewThemeData.lerp(data, data, 0.5), data), true); + }); + + test('SearchViewThemeData defaults', () { + const SearchViewThemeData themeData = SearchViewThemeData(); + expect(themeData.backgroundColor, null); + expect(themeData.elevation, null); + expect(themeData.surfaceTintColor, null); + expect(themeData.constraints, null); + expect(themeData.side, null); + expect(themeData.shape, null); + expect(themeData.headerTextStyle, null); + expect(themeData.headerHintStyle, null); + expect(themeData.dividerColor, null); + + const SearchViewTheme theme = SearchViewTheme(data: SearchViewThemeData(), child: SizedBox()); + expect(theme.data.backgroundColor, null); + expect(theme.data.elevation, null); + expect(theme.data.surfaceTintColor, null); + expect(theme.data.constraints, null); + expect(theme.data.side, null); + expect(theme.data.shape, null); + expect(theme.data.headerTextStyle, null); + expect(theme.data.headerHintStyle, null); + expect(theme.data.dividerColor, null); + }); + + testWidgets('Default SearchViewThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const SearchViewThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('SearchViewThemeData implements debugFillProperties', ( + WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const SearchViewThemeData( + backgroundColor: Color(0xfffffff1), + elevation: 3.5, + surfaceTintColor: Color(0xfffffff3), + side: BorderSide(width: 2.5, color: Color(0xfffffff5)), + shape: RoundedRectangleBorder(), + headerTextStyle: TextStyle(fontSize: 24.0), + headerHintStyle: TextStyle(fontSize: 16.0), + constraints: BoxConstraints(minWidth: 350, minHeight: 240), + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description[0], 'backgroundColor: Color(0xfffffff1)'); + expect(description[1], 'elevation: 3.5'); + expect(description[2], 'surfaceTintColor: Color(0xfffffff3)'); + expect(description[3], 'side: BorderSide(color: Color(0xfffffff5), width: 2.5)'); + expect(description[4], 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.zero)'); + expect(description[5], 'headerTextStyle: TextStyle(inherit: true, size: 24.0)'); + expect(description[6], 'headerHintStyle: TextStyle(inherit: true, size: 16.0)'); + expect(description[7], 'constraints: BoxConstraints(350.0<=w<=Infinity, 240.0<=h<=Infinity)'); + }); + + group('[Theme, SearchViewTheme, SearchView properties overrides]', () { + const Color backgroundColor = Color(0xff000001); + const double elevation = 5.0; + const Color surfaceTintColor = Color(0xff000002); + const BorderSide side = BorderSide(color: Color(0xff000003), width: 2.0); + const OutlinedBorder shape = RoundedRectangleBorder(side: side, borderRadius: BorderRadius.all(Radius.circular(20.0))); + const TextStyle headerTextStyle = TextStyle(color: Color(0xff000004), fontSize: 20.0); + const TextStyle headerHintStyle = TextStyle(color: Color(0xff000005), fontSize: 18.0); + const BoxConstraints constraints = BoxConstraints(minWidth: 250.0, maxWidth: 300.0, minHeight: 450.0); + + const SearchViewThemeData searchViewTheme = SearchViewThemeData( + backgroundColor: backgroundColor, + elevation: elevation, + surfaceTintColor: surfaceTintColor, + side: side, + shape: shape, + headerTextStyle: headerTextStyle, + headerHintStyle: headerHintStyle, + constraints: constraints, + ); + + Widget buildFrame({ + bool useSearchViewProperties = false, + SearchViewThemeData? searchViewThemeData, + SearchViewThemeData? overallTheme + }) { + final Widget child = Builder( + builder: (BuildContext context) { + if (!useSearchViewProperties) { + return SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + isFullScreen: false, + ); + } + return SearchAnchor( + viewHintText: 'hint text', + builder: (BuildContext context, SearchController controller) { + return const Icon(Icons.search); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + isFullScreen: false, + viewElevation: elevation, + viewBackgroundColor: backgroundColor, + viewSurfaceTintColor: surfaceTintColor, + viewSide: side, + viewShape: shape, + headerTextStyle: headerTextStyle, + headerHintStyle: headerHintStyle, + viewConstraints: constraints, + ); + }, + ); + return MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), useMaterial3: true) + .copyWith( + searchViewTheme: overallTheme, + ), + home: Scaffold( + body: Center( + // If the SearchViewThemeData widget is present, it's used + // instead of the Theme's ThemeData.searchViewTheme. + child: searchViewThemeData == null ? child : SearchViewTheme( + data: searchViewThemeData, + child: child, + ), + ), + ), + ); + } + + Finder findViewContent() { + return find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_ViewContent'; + }); + } + + Material getSearchViewMaterial(WidgetTester tester) { + return tester.widget(find.descendant(of: findViewContent(), matching: find.byType(Material)).first); + } + + Future checkSearchView(WidgetTester tester) async { + final Material material = getSearchViewMaterial(tester); + expect(material.elevation, elevation); + expect(material.color, backgroundColor); + expect(material.surfaceTintColor, surfaceTintColor); + expect(material.shape, shape); + + final SizedBox sizedBox = tester.widget(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); + expect(sizedBox.width, 250.0); + expect(sizedBox.height, 450.0); + + final Text hintText = tester.widget(find.text('hint text')); + expect(hintText.style?.color, headerHintStyle.color); + expect(hintText.style?.fontSize, headerHintStyle.fontSize); + + await tester.enterText(find.byType(TextField), 'input'); + final EditableText inputText = tester.widget(find.text('input')); + expect(inputText.style.color, headerTextStyle.color); + expect(inputText.style.fontSize, headerTextStyle.fontSize); + } + + testWidgets('SearchView properties overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(useSearchViewProperties: true)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + checkSearchView(tester); + }); + + testWidgets('SearchView theme data overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(searchViewThemeData: searchViewTheme)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + checkSearchView(tester); + }); + + testWidgets('Overall Theme SearchView theme overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallTheme: searchViewTheme)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + checkSearchView(tester); + }); + + // Same as the previous tests with empty SearchViewThemeData's instead of null. + + testWidgets('SearchView properties overrides defaults, empty theme and overall theme', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(useSearchViewProperties: true, + searchViewThemeData: const SearchViewThemeData(), + overallTheme: const SearchViewThemeData())); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + checkSearchView(tester); + }); + + testWidgets('SearchView theme overrides defaults and overall theme', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(searchViewThemeData: searchViewTheme, + overallTheme: const SearchViewThemeData())); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + checkSearchView(tester); + }); + + testWidgets('Overall Theme SearchView theme overrides defaults and null theme', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallTheme: searchViewTheme)); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); // allow the animations to finish + checkSearchView(tester); + }); + }); +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index f91bbd3f6cf..451b2022352 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -787,6 +787,7 @@ void main() { progressIndicatorTheme: const ProgressIndicatorThemeData(), radioTheme: const RadioThemeData(), searchBarTheme: const SearchBarThemeData(), + searchViewTheme: const SearchViewThemeData(), segmentedButtonTheme: const SegmentedButtonThemeData(), sliderTheme: sliderTheme, snackBarTheme: const SnackBarThemeData(backgroundColor: Colors.black), @@ -906,6 +907,7 @@ void main() { progressIndicatorTheme: const ProgressIndicatorThemeData(), radioTheme: const RadioThemeData(), searchBarTheme: const SearchBarThemeData(), + searchViewTheme: const SearchViewThemeData(), segmentedButtonTheme: const SegmentedButtonThemeData(), sliderTheme: otherSliderTheme, snackBarTheme: const SnackBarThemeData(backgroundColor: Colors.white), @@ -1010,6 +1012,7 @@ void main() { progressIndicatorTheme: otherTheme.progressIndicatorTheme, radioTheme: otherTheme.radioTheme, searchBarTheme: otherTheme.searchBarTheme, + searchViewTheme: otherTheme.searchViewTheme, sliderTheme: otherTheme.sliderTheme, snackBarTheme: otherTheme.snackBarTheme, switchTheme: otherTheme.switchTheme, @@ -1111,6 +1114,7 @@ void main() { expect(themeDataCopy.progressIndicatorTheme, equals(otherTheme.progressIndicatorTheme)); expect(themeDataCopy.radioTheme, equals(otherTheme.radioTheme)); expect(themeDataCopy.searchBarTheme, equals(otherTheme.searchBarTheme)); + expect(themeDataCopy.searchViewTheme, equals(otherTheme.searchViewTheme)); expect(themeDataCopy.sliderTheme, equals(otherTheme.sliderTheme)); expect(themeDataCopy.snackBarTheme, equals(otherTheme.snackBarTheme)); expect(themeDataCopy.switchTheme, equals(otherTheme.switchTheme)); @@ -1249,6 +1253,7 @@ void main() { 'progressIndicatorTheme', 'radioTheme', 'searchBarTheme', + 'searchViewTheme', 'segmentedButtonTheme', 'sliderTheme', 'snackBarTheme',