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
This commit is contained in:
parent
8b150bd076
commit
3225aa5815
@ -707,7 +707,6 @@ class _ViewContentState extends State<_ViewContent> {
|
||||
late Rect _viewRect;
|
||||
late final SearchController _controller;
|
||||
Iterable<Widget> result = <Widget>[];
|
||||
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<SearchBar> createState() => _SearchBarState();
|
||||
}
|
||||
@ -1311,7 +1309,9 @@ class _SearchBarState extends State<SearchBar> {
|
||||
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<SearchBar> {
|
||||
children: <Widget>[
|
||||
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,
|
||||
],
|
||||
|
@ -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}
|
||||
|
@ -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<TextSelectionGestureDetec
|
||||
void _handleTapUp(TapDragUpDetails details) {
|
||||
if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 1) {
|
||||
widget.onSingleTapUp?.call(details);
|
||||
widget.onUserTap?.call();
|
||||
} else if (widget.onUserTapAlwaysCalled) {
|
||||
widget.onUserTap?.call();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,11 +4,53 @@
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
|
||||
|
||||
void main() {
|
||||
// Returns the RenderEditable at the given index, or the first if not given.
|
||||
RenderEditable findRenderEditable(WidgetTester tester, {int index = 0}) {
|
||||
final RenderObject root = tester.renderObject(find.byType(EditableText).at(index));
|
||||
expect(root, isNotNull);
|
||||
|
||||
late RenderEditable renderEditable;
|
||||
void recursiveFinder(RenderObject child) {
|
||||
if (child is RenderEditable) {
|
||||
renderEditable = child;
|
||||
return;
|
||||
}
|
||||
child.visitChildren(recursiveFinder);
|
||||
}
|
||||
root.visitChildren(recursiveFinder);
|
||||
expect(renderEditable, isNotNull);
|
||||
return renderEditable;
|
||||
}
|
||||
|
||||
List<TextSelectionPoint> globalize(Iterable<TextSelectionPoint> points, RenderBox box) {
|
||||
return points.map<TextSelectionPoint>((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<TextSelectionPoint> 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<Material>(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<Material>(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<Material>(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<Material>(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<Material>(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<Material>(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<Material>(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<Material>(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<Material>(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<Material>(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 <Widget>[
|
||||
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 <Widget>[
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),),
|
||||
);
|
||||
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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 <Widget>[];
|
||||
},
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
|
||||
// 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 <Widget>[];
|
||||
},
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
|
||||
// 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<Widget>.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<Widget>.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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
));
|
||||
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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 <Widget>[];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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', () {
|
||||
|
Loading…
x
Reference in New Issue
Block a user