From 3225aa58159234e70e857e9888a128da2abfe979 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Thu, 9 Nov 2023 14:12:01 -0800 Subject: [PATCH] Fix text selection in `SearchAnchor/SearchBar` (#137636) This changes fixes text selection gestures on the search field when using `SearchAnchor`. Before this change text selection gestures did not work due to an `IgnorePointer` in the widget tree. This change: * Removes the `IgnorePointer` so the underlying `TextField` can receive pointer events. * Introduces `TextField.onTapAlwaysCalled` and `TextSelectionGestureDetector.onUserTapAlwaysCalled`, so a user provided on tap callback can be called on consecutive taps. This is so that the user provided on tap callback for `SearchAnchor/SearchBar` that was previously only handled by `InkWell` will still work if a tap occurs in the `TextField`s hit box. The `TextField`s default behavior is maintained outside of the context of `SearchAnchor/SearchBar`. Fixes https://github.com/flutter/flutter/issues/128332 and #134965 --- .../lib/src/material/search_anchor.dart | 70 +- .../flutter/lib/src/material/text_field.dart | 19 +- .../lib/src/widgets/text_selection.dart | 42 +- .../test/material/search_anchor_test.dart | 649 ++++++++++++------ 4 files changed, 539 insertions(+), 241 deletions(-) diff --git a/packages/flutter/lib/src/material/search_anchor.dart b/packages/flutter/lib/src/material/search_anchor.dart index e4b2696ae9..0fd34c6e7d 100644 --- a/packages/flutter/lib/src/material/search_anchor.dart +++ b/packages/flutter/lib/src/material/search_anchor.dart @@ -707,7 +707,6 @@ class _ViewContentState extends State<_ViewContent> { late Rect _viewRect; late final SearchController _controller; Iterable result = []; - final FocusNode _focusNode = FocusNode(); @override void initState() { @@ -715,10 +714,6 @@ class _ViewContentState extends State<_ViewContent> { _viewRect = widget.viewRect; _controller = widget.searchController; _controller.addListener(updateSuggestions); - - if (!_focusNode.hasFocus) { - _focusNode.requestFocus(); - } } @override @@ -748,7 +743,6 @@ class _ViewContentState extends State<_ViewContent> { @override void dispose() { _controller.removeListener(updateSuggestions); - _focusNode.dispose(); super.dispose(); } @@ -865,8 +859,8 @@ class _ViewContentState extends State<_ViewContent> { top: false, bottom: false, child: SearchBar( + autoFocus: true, constraints: widget.showFullScreenView ? BoxConstraints(minHeight: _SearchViewDefaultsM3.fullScreenBarHeight) : null, - focusNode: _focusNode, leading: widget.viewLeading ?? defaultLeading, trailing: widget.viewTrailing ?? defaultTrailing, hintText: widget.viewHintText, @@ -1091,6 +1085,7 @@ class SearchBar extends StatefulWidget { this.textStyle, this.hintStyle, this.textCapitalization, + this.autoFocus = false, }); /// Controls the text being edited in the search bar's text field. @@ -1212,6 +1207,9 @@ class SearchBar extends StatefulWidget { /// {@macro flutter.widgets.editableText.textCapitalization} final TextCapitalization? textCapitalization; + /// {@macro flutter.widgets.editableText.autofocus} + final bool autoFocus; + @override State createState() => _SearchBarState(); } @@ -1311,7 +1309,9 @@ class _SearchBarState extends State { child: InkWell( onTap: () { widget.onTap?.call(); - _focusNode.requestFocus(); + if (!_focusNode.hasFocus) { + _focusNode.requestFocus(); + } }, overlayColor: effectiveOverlayColor, customBorder: effectiveShape?.copyWith(side: effectiveSide), @@ -1323,34 +1323,34 @@ class _SearchBarState extends State { children: [ if (leading != null) leading, Expanded( - child: IgnorePointer( - child: Padding( - padding: effectivePadding, - child: TextField( - focusNode: _focusNode, - onChanged: widget.onChanged, - onSubmitted: widget.onSubmitted, - controller: widget.controller, - style: effectiveTextStyle, - decoration: InputDecoration( - hintText: widget.hintText, - ).applyDefaults(InputDecorationTheme( - hintStyle: effectiveHintStyle, - - // The configuration below is to make sure that the text field - // in `SearchBar` will not be overridden by the overall `InputDecorationTheme` - enabledBorder: InputBorder.none, - border: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - // Setting `isDense` to true to allow the text field height to be - // smaller than 48.0 - isDense: true, - )), - textCapitalization: effectiveTextCapitalization, - ), + child: Padding( + padding: effectivePadding, + child: TextField( + autofocus: widget.autoFocus, + onTap: widget.onTap, + onTapAlwaysCalled: true, + focusNode: _focusNode, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + controller: widget.controller, + style: effectiveTextStyle, + decoration: InputDecoration( + hintText: widget.hintText, + ).applyDefaults(InputDecorationTheme( + hintStyle: effectiveHintStyle, + // The configuration below is to make sure that the text field + // in `SearchBar` will not be overridden by the overall `InputDecorationTheme` + enabledBorder: InputBorder.none, + border: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + // Setting `isDense` to true to allow the text field height to be + // smaller than 48.0 + isDense: true, + )), + textCapitalization: effectiveTextCapitalization, ), - ) + ), ), if (trailing != null) ...trailing, ], diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 874c1100b1..f508d70abb 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -69,6 +69,13 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete void onSingleTapUp(TapDragUpDetails details) { super.onSingleTapUp(details); _state._requestKeyboard(); + } + + @override + bool get onUserTapAlwaysCalled => _state.widget.onTapAlwaysCalled; + + @override + void onUserTap() { _state.widget.onTap?.call(); } @@ -297,6 +304,7 @@ class TextField extends StatefulWidget { bool? enableInteractiveSelection, this.selectionControls, this.onTap, + this.onTapAlwaysCalled = false, this.onTapOutside, this.mouseCursor, this.buildCounter, @@ -636,7 +644,7 @@ class TextField extends StatefulWidget { bool get selectionEnabled => enableInteractiveSelection; /// {@template flutter.material.textfield.onTap} - /// Called for each distinct tap except for every second tap of a double tap. + /// Called for the first tap in a series of taps. /// /// The text field builds a [GestureDetector] to handle input events like tap, /// to trigger focus requests, to move the caret, adjust the selection, etc. @@ -655,8 +663,17 @@ class TextField extends StatefulWidget { /// To listen to arbitrary pointer events without competing with the /// text field's internal gesture detector, use a [Listener]. /// {@endtemplate} + /// + /// If [onTapAlwaysCalled] is enabled, this will also be called for consecutive + /// taps. final GestureTapCallback? onTap; + /// Whether [onTap] should be called for every tap. + /// + /// Defaults to false, so [onTap] is only called for each distinct tap. When + /// enabled, [onTap] is called for every tap including consecutive taps. + final bool onTapAlwaysCalled; + /// {@macro flutter.widgets.editableText.onTapOutside} /// /// {@tool dartpad} diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index e85365d006..3e760701c6 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -2268,6 +2268,27 @@ class TextSelectionGestureDetectorBuilder { } } + /// Whether the provided [onUserTap] callback should be dispatched on every + /// tap or only non-consecutive taps. + /// + /// Defaults to false. + @protected + bool get onUserTapAlwaysCalled => false; + + /// Handler for [TextSelectionGestureDetector.onUserTap]. + /// + /// By default, it serves as placeholder to enable subclass override. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onUserTap], which triggers this + /// callback. + /// * [TextSelectionGestureDetector.onUserTapAlwaysCalled], which controls + /// whether this callback is called only on the first tap in a series + /// of taps. + @protected + void onUserTap() { /* Subclass should override this method if needed. */ } + /// Handler for [TextSelectionGestureDetector.onSingleTapUp]. /// /// By default, it selects word edge if selection is enabled. @@ -2371,7 +2392,7 @@ class TextSelectionGestureDetectorBuilder { /// Handler for [TextSelectionGestureDetector.onSingleTapCancel]. /// - /// By default, it services as place holder to enable subclass override. + /// By default, it serves as placeholder to enable subclass override. /// /// See also: /// @@ -2992,6 +3013,7 @@ class TextSelectionGestureDetectorBuilder { onSecondaryTapDown: onSecondaryTapDown, onSingleTapUp: onSingleTapUp, onSingleTapCancel: onSingleTapCancel, + onUserTap: onUserTap, onSingleLongTapStart: onSingleLongTapStart, onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, onSingleLongTapEnd: onSingleLongTapEnd, @@ -3000,6 +3022,7 @@ class TextSelectionGestureDetectorBuilder { onDragSelectionStart: onDragSelectionStart, onDragSelectionUpdate: onDragSelectionUpdate, onDragSelectionEnd: onDragSelectionEnd, + onUserTapAlwaysCalled: onUserTapAlwaysCalled, behavior: behavior, child: child, ); @@ -3033,6 +3056,7 @@ class TextSelectionGestureDetector extends StatefulWidget { this.onSecondaryTapDown, this.onSingleTapUp, this.onSingleTapCancel, + this.onUserTap, this.onSingleLongTapStart, this.onSingleLongTapMoveUpdate, this.onSingleLongTapEnd, @@ -3041,6 +3065,7 @@ class TextSelectionGestureDetector extends StatefulWidget { this.onDragSelectionStart, this.onDragSelectionUpdate, this.onDragSelectionEnd, + this.onUserTapAlwaysCalled = false, this.behavior, required this.child, }); @@ -3083,6 +3108,13 @@ class TextSelectionGestureDetector extends StatefulWidget { /// another gesture from the touch is recognized. final GestureCancelCallback? onSingleTapCancel; + /// Called for the first tap in a series of taps when [onUserTapAlwaysCalled] is + /// disabled, which is the default behavior. + /// + /// When [onUserTapAlwaysCalled] is enabled, this is called for every tap, + /// including consecutive taps. + final GestureTapCallback? onUserTap; + /// Called for a single long tap that's sustained for longer than /// [kLongPressTimeout] but not necessarily lifted. Not called for a /// double-tap-hold, which calls [onDoubleTapDown] instead. @@ -3111,6 +3143,11 @@ class TextSelectionGestureDetector extends StatefulWidget { /// Called when a mouse that was previously dragging is released. final GestureTapDragEndCallback? onDragSelectionEnd; + /// Whether [onUserTap] will be called for all taps including consecutive taps. + /// + /// Defaults to false, so [onUserTap] is only called for each distinct tap. + final bool onUserTapAlwaysCalled; + /// How this gesture detector should behave during hit testing. /// /// This defaults to [HitTestBehavior.deferToChild]. @@ -3189,6 +3226,9 @@ class _TextSelectionGestureDetectorState extends State globalize(Iterable points, RenderBox box) { + return points.map((TextSelectionPoint point) { + return TextSelectionPoint( + box.localToGlobal(point.point), + point.direction, + ); + }).toList(); + } + + Offset textOffsetToPosition(WidgetTester tester, int offset, {int index = 0}) { + final RenderEditable renderEditable = findRenderEditable(tester, index: index); + final List endpoints = globalize( + renderEditable.getEndpointsForSelection( + TextSelection.collapsed(offset: offset), + ), + renderEditable, + ); + expect(endpoints.length, 1); + return endpoints[0].point + const Offset(kIsWeb? 1.0 : 0.0, -2.0); + } + testWidgetsWithLeakTracking('SearchBar defaults', (WidgetTester tester) async { final ThemeData theme = ThemeData(useMaterial3: true); final ColorScheme colorScheme = theme.colorScheme; @@ -420,14 +462,16 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); - await gesture.removePointer(); + await tester.pumpAndSettle(); material = tester.widget(searchBarMaterial); expect(material.elevation, pressedElevation); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); material = tester.widget(searchBarMaterial); expect(material.elevation, focusedElevation); @@ -460,14 +504,16 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); - await gesture.removePointer(); + await tester.pumpAndSettle(); material = tester.widget(searchBarMaterial); expect(material.color, pressedColor); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); material = tester.widget(searchBarMaterial); expect(material.color, focusedColor); @@ -500,14 +546,16 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); - await gesture.removePointer(); + await tester.pumpAndSettle(); material = tester.widget(searchBarMaterial); expect(material.shadowColor, pressedColor); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); material = tester.widget(searchBarMaterial); expect(material.shadowColor, focusedColor); @@ -540,14 +588,16 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); - await gesture.removePointer(); + await tester.pumpAndSettle(); material = tester.widget(searchBarMaterial); expect(material.surfaceTintColor, pressedColor); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); material = tester.widget(searchBarMaterial); expect(material.surfaceTintColor, focusedColor); @@ -579,16 +629,18 @@ void main() { // On pressed. await tester.pumpAndSettle(); - await tester.startGesture(tester.getCenter(find.byType(SearchBar))); + await gesture.down(tester.getCenter(find.byType(SearchBar))); await tester.pumpAndSettle(); inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); expect(inkFeatures, paints..rect()..rect(color: pressedColor.withOpacity(1.0))); - await gesture.removePointer(); // On focused. await tester.pumpAndSettle(); - focusNode.requestFocus(); + await gesture.up(); await tester.pumpAndSettle(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); + await tester.pump(); inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); expect(inkFeatures, paints..rect()..rect(color: focusedColor.withOpacity(1.0))); }); @@ -654,14 +706,16 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); - await gesture.removePointer(); + await tester.pumpAndSettle(); material = tester.widget(searchBarMaterial); expect(material.shape, pressedShape.copyWith(side: pressedSide)); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); material = tester.widget(searchBarMaterial); expect(material.shape, focusedShape.copyWith(side: focusedSide)); @@ -717,13 +771,15 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); + await tester.pumpAndSettle(); helperText = tester.widget(find.text('hint text')); expect(helperText.style?.color, pressedColor); - await gesture.removePointer(); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); helperText = tester.widget(find.text('hint text')); expect(helperText.style?.color, focusedColor); @@ -754,13 +810,15 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); - await gesture.removePointer(); + await tester.pumpAndSettle(); inputText = tester.widget(find.text('input text')); expect(inputText.style.color, pressedColor); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); inputText = tester.widget(find.text('input text')); expect(inputText.style.color, focusedColor); @@ -1003,13 +1061,15 @@ void main() { // On pressed. await gesture.down(tester.getCenter(find.byType(SearchBar))); - await tester.pump(); - await gesture.removePointer(); + await tester.pumpAndSettle(); helperText = tester.widget(find.text('hint text')); expect(helperText.style?.color, pressedColor); // On focused. - await tester.tap(find.byType(SearchBar)); + await gesture.up(); + await tester.pump(); + // Remove the pointer so we are no longer hovering. + await gesture.removePointer(); await tester.pump(); helperText = tester.widget(find.text('hint text')); expect(helperText.style?.color, focusedColor); @@ -1922,23 +1982,25 @@ void main() { final SearchController controller = SearchController(); addTearDown(controller.dispose); - 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'); - }, - ) - ]; - }, + 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); @@ -1954,51 +2016,13 @@ void main() { }); testWidgetsWithLeakTracking('Search view does not go off the screen - LTR', (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: Material( - child: Align( - // Put the search anchor on the bottom-right corner of the screen to test - // if the search view goes off the window. - alignment: Alignment.bottomRight, - child: SearchAnchor( - isFullScreen: false, - builder: (BuildContext context, SearchController controller) { - return IconButton( - icon: const Icon(Icons.search), - onPressed: () { - controller.openView(); - }, - ); - }, - suggestionsBuilder: (BuildContext context, SearchController controller) { - return []; - }, - ), - ), - ),), - ); - - final Finder findIconButton = find.widgetWithIcon(IconButton, Icons.search); - final Rect iconButton = tester.getRect(findIconButton); - // Icon button has a size of (48.0, 48.0) and the screen size is (800.0, 600.0). - expect(iconButton, equals(const Rect.fromLTRB(752.0, 552.0, 800.0, 600.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(440.0, 200.0, 800.0, 600.0))); - }); - - testWidgetsWithLeakTracking('Search view does not go off the screen - RTL', (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - home: Directionality( - textDirection: TextDirection.rtl, - child: Material( + await tester.pumpWidget( + MaterialApp( + home: Material( child: Align( - // Put the search anchor on the bottom-left corner of the screen to test - // if the search view goes off the window when the text direction is right-to-left. - alignment: Alignment.bottomLeft, + // Put the search anchor on the bottom-right corner of the screen to test + // if the search view goes off the window. + alignment: Alignment.bottomRight, child: SearchAnchor( isFullScreen: false, builder: (BuildContext context, SearchController controller) { @@ -2015,7 +2039,49 @@ void main() { ), ), ), - ),), + ), + ); + + final Finder findIconButton = find.widgetWithIcon(IconButton, Icons.search); + final Rect iconButton = tester.getRect(findIconButton); + // Icon button has a size of (48.0, 48.0) and the screen size is (800.0, 600.0). + expect(iconButton, equals(const Rect.fromLTRB(752.0, 552.0, 800.0, 600.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(440.0, 200.0, 800.0, 600.0))); + }); + + testWidgetsWithLeakTracking('Search view does not go off the screen - RTL', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Material( + child: Align( + // Put the search anchor on the bottom-left corner of the screen to test + // if the search view goes off the window when the text direction is right-to-left. + alignment: Alignment.bottomLeft, + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ), + ), ); final Finder findIconButton = find.widgetWithIcon(IconButton, Icons.search); @@ -2057,7 +2123,8 @@ void main() { }, ), ), - ),); + ), + ); } // Test LTR text direction. @@ -2095,27 +2162,29 @@ void main() { tester.view.physicalSize = const Size(500.0, 600.0); tester.view.devicePixelRatio = 1.0; - await tester.pumpWidget(MaterialApp( - home: Material( - child: SearchAnchor( - isFullScreen: false, - builder: (BuildContext context, SearchController controller) { - return Align( - alignment: Alignment.bottomRight, - child: IconButton( - icon: const Icon(Icons.search), - onPressed: () { - controller.openView(); - }, - ), - ); - }, - suggestionsBuilder: (BuildContext context, SearchController controller) { - return []; - }, + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: false, + builder: (BuildContext context, SearchController controller) { + return Align( + alignment: Alignment.bottomRight, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), ), ), - )); + ); // Open the search view await tester.tap(find.byIcon(Icons.search)); @@ -2134,27 +2203,29 @@ void main() { tester.view.physicalSize = const Size(500.0, 600.0); tester.view.devicePixelRatio = 1.0; - await tester.pumpWidget(MaterialApp( - home: Material( - child: SearchAnchor( - isFullScreen: true, - builder: (BuildContext context, SearchController controller) { - return Align( - alignment: Alignment.bottomRight, - child: IconButton( - icon: const Icon(Icons.search), - onPressed: () { - controller.openView(); - }, - ), - ); - }, - suggestionsBuilder: (BuildContext context, SearchController controller) { - return []; - }, + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor( + isFullScreen: true, + builder: (BuildContext context, SearchController controller) { + return Align( + alignment: Alignment.bottomRight, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + controller.openView(); + }, + ), + ); + }, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), ), ), - )); + ); // Open a full-screen search view await tester.tap(find.byIcon(Icons.search)); @@ -2170,32 +2241,34 @@ void main() { testWidgetsWithLeakTracking('Search view route does not throw exception during pop animation', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/126590. - await tester.pumpWidget(MaterialApp( - home: Material( - child: Center( - child: SearchAnchor( - 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( - leading: const Icon(Icons.history), - title: Text(item), - trailing: const Icon(Icons.chevron_right), - onTap: () {}, + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: SearchAnchor( + 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( + leading: const Icon(Icons.history), + title: Text(item), + trailing: const Icon(Icons.chevron_right), + onTap: () {}, + ); + }); + }), + ), ), ), - )); + ); // Open search view await tester.tap(find.byIcon(Icons.search)); @@ -2211,59 +2284,17 @@ void main() { testWidgetsWithLeakTracking('Docked search should position itself correctly based on closest navigator', (WidgetTester tester) async { const double rootSpacing = 100.0; - await tester.pumpWidget(MaterialApp( - builder: (BuildContext context, Widget? child) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(rootSpacing), - child: child, - ), - ); - }, - home: Material( - child: SearchAnchor( - isFullScreen: false, - 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.byIcon(Icons.search)); - await tester.pumpAndSettle(); - - final Rect searchViewRect = tester.getRect(find.descendant(of: findViewContent(), matching: find.byType(SizedBox)).first); - expect(searchViewRect.topLeft, equals(const Offset(rootSpacing, rootSpacing))); - }); - - testWidgetsWithLeakTracking('Docked search view with nested navigator does not go off the screen', (WidgetTester tester) async { - addTearDown(tester.view.reset); - tester.view.physicalSize = const Size(400.0, 400.0); - tester.view.devicePixelRatio = 1.0; - - const double rootSpacing = 100.0; - - await tester.pumpWidget(MaterialApp( - builder: (BuildContext context, Widget? child) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(rootSpacing), - child: child, - ), - ); - }, - home: Material( - child: Align( - alignment: Alignment.bottomRight, + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(rootSpacing), + child: child, + ), + ); + }, + home: Material( child: SearchAnchor( isFullScreen: false, builder: (BuildContext context, SearchController controller) { @@ -2280,7 +2311,53 @@ void main() { ), ), ), - )); + ); + + 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.topLeft, equals(const Offset(rootSpacing, rootSpacing))); + }); + + testWidgetsWithLeakTracking('Docked search view with nested navigator does not go off the screen', (WidgetTester tester) async { + addTearDown(tester.view.reset); + tester.view.physicalSize = const Size(400.0, 400.0); + tester.view.devicePixelRatio = 1.0; + + const double rootSpacing = 100.0; + + await tester.pumpWidget( + MaterialApp( + builder: (BuildContext context, Widget? child) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(rootSpacing), + child: child, + ), + ); + }, + home: Material( + child: Align( + alignment: Alignment.bottomRight, + child: SearchAnchor( + isFullScreen: false, + 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.byIcon(Icons.search)); await tester.pumpAndSettle(); @@ -2289,6 +2366,170 @@ void main() { expect(searchViewRect.bottomRight, equals(const Offset(300.0, 300.0))); }); + // Regression tests for https://github.com/flutter/flutter/issues/128332 + group('SearchAnchor text selection', () { + testWidgetsWithLeakTracking('can right-click to select word', (WidgetTester tester) async { + const String defaultText = 'initial text'; + final SearchController controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await tester.startGesture( + textOffsetToPosition(tester, 4) + const Offset(0.0, -9.0), + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); + + testWidgetsWithLeakTracking('can click to set position', (WidgetTester tester) async { + const String defaultText = 'initial text'; + final SearchController controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await _pointGestureToSearchBar(tester); + await gesture.down(textOffsetToPosition(tester, 2) + const Offset(0.0, -9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(controller.value.selection, const TextSelection.collapsed(offset: 2)); + + await gesture.down(textOffsetToPosition(tester, 9, index: 1) + const Offset(0.0, -9.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, const TextSelection.collapsed(offset: 9)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgetsWithLeakTracking('can double-click to select word', (WidgetTester tester) async { + const String defaultText = 'initial text'; + final SearchController controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await _pointGestureToSearchBar(tester); + final Offset targetPosition = textOffsetToPosition(tester, 4) + const Offset(0.0, -9.0); + await gesture.down(targetPosition); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final Offset targetPositionAfterViewOpened = textOffsetToPosition(tester, 4, index: 1) + const Offset(0.0, -9.0); + await gesture.down(targetPositionAfterViewOpened); + await tester.pumpAndSettle(); + await gesture.up(); + await tester.pump(); + + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pump(); + expect(controller.value.selection, const TextSelection(baseOffset: 0, extentOffset: 7)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.desktop()); + + testWidgetsWithLeakTracking('can triple-click to select field', (WidgetTester tester) async { + const String defaultText = 'initial text'; + final SearchController controller = SearchController(); + addTearDown(controller.dispose); + controller.text = defaultText; + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: SearchAnchor.bar( + searchController: controller, + suggestionsBuilder: (BuildContext context, SearchController controller) { + return []; + }, + ), + ), + ), + ); + + expect(controller.value.text, defaultText); + expect(find.text(defaultText), findsOneWidget); + + final TestGesture gesture = await _pointGestureToSearchBar(tester); + final Offset targetPosition = textOffsetToPosition(tester, 4) + const Offset(0.0, -9.0); + await gesture.down(targetPosition); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + + final Offset targetPositionAfterViewOpened = textOffsetToPosition(tester, 4, index: 1) + const Offset(0.0, -9.0); + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pump(); + + await gesture.down(targetPositionAfterViewOpened); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.value.selection, const TextSelection(baseOffset: 0, extentOffset: 12)); + await gesture.removePointer(); + }, variant: TargetPlatformVariant.desktop()); + }); // Regression tests for https://github.com/flutter/flutter/issues/126623 group('Overall InputDecorationTheme does not impact SearchBar and SearchView', () {