Unverified Commit 18ca3754 authored by Mouad Debbar's avatar Mouad Debbar Committed by GitHub

Show/hide toolbar and handles based on device kind (#29683)

parent fe8cddbb
......@@ -469,6 +469,12 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
FocusNode _focusNode;
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
// The selection overlay should only be shown when the user is interacting
// through a touch screen (via either a finger or a stylus). A mouse shouldn't
// trigger the selection overlay.
// For backwards-compatibility, we treat a null kind the same as touch.
bool _shouldShowSelectionToolbar = true;
@override
void initState() {
super.initState();
......@@ -501,14 +507,26 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
super.dispose();
}
EditableTextState get _editableText => _editableTextKey.currentState;
void _requestKeyboard() {
_editableTextKey.currentState?.requestKeyboard();
_editableText?.requestKeyboard();
}
RenderEditable get _renderEditable => _editableTextKey.currentState.renderEditable;
RenderEditable get _renderEditable => _editableText.renderEditable;
void _handleTapDown(TapDownDetails details) {
_renderEditable.handleTapDown(details);
// The selection overlay should only be shown when the user is interacting
// through a touch screen (via either a finger or a stylus). A mouse shouldn't
// trigger the selection overlay.
// For backwards-compatibility, we treat a null kind the same as touch.
final PointerDeviceKind kind = details.kind;
_shouldShowSelectionToolbar =
kind == null ||
kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus;
}
void _handleForcePressStarted(ForcePressDetails details) {
......@@ -523,7 +541,8 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
from: details.globalPosition,
cause: SelectionChangedCause.forcePress,
);
_editableTextKey.currentState.showToolbar();
if (_shouldShowSelectionToolbar)
_editableText.showToolbar();
}
void _handleSingleTapUp(TapUpDetails details) {
......@@ -546,12 +565,33 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
}
void _handleSingleLongTapEnd(LongPressEndDetails details) {
_editableTextKey.currentState.showToolbar();
if (_shouldShowSelectionToolbar)
_editableText.showToolbar();
}
void _handleDoubleTapDown(TapDownDetails details) {
_renderEditable.selectWord(cause: SelectionChangedCause.tap);
_editableTextKey.currentState.showToolbar();
if (_shouldShowSelectionToolbar)
_editableText.showToolbar();
}
bool _shouldShowSelectionHandles(SelectionChangedCause cause) {
// When the text field is activated by something that doesn't trigger the
// selection overlay, we shouldn't show the handles either.
if (!_shouldShowSelectionToolbar)
return false;
// On iOS, we don't show handles when the selection is collapsed.
if (_effectiveController.selection.isCollapsed)
return false;
if (cause == SelectionChangedCause.keyboard)
return false;
if (_effectiveController.text.isNotEmpty)
return true;
return false;
}
void _handleMouseDragSelectionStart(DragStartDetails details) {
......@@ -578,7 +618,10 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with AutomaticK
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) {
if (cause == SelectionChangedCause.longPress) {
_editableTextKey.currentState?.bringIntoView(selection.base);
_editableText?.bringIntoView(selection.base);
}
if (_shouldShowSelectionHandles(cause)) {
_editableText?.showHandles();
}
}
......
......@@ -397,7 +397,12 @@ class MultiTapGestureRecognizer extends GestureRecognizer {
longTapDelay: longTapDelay,
);
if (onTapDown != null)
invokeCallback<void>('onTapDown', () => onTapDown(event.pointer, TapDownDetails(globalPosition: event.position)));
invokeCallback<void>('onTapDown', () {
onTapDown(event.pointer, TapDownDetails(
globalPosition: event.position,
kind: event.kind,
));
});
}
@override
......@@ -432,7 +437,15 @@ class MultiTapGestureRecognizer extends GestureRecognizer {
void _dispatchLongTap(int pointer, Offset lastPosition) {
assert(_gestureMap.containsKey(pointer));
if (onLongTapDown != null)
invokeCallback<void>('onLongTapDown', () => onLongTapDown(pointer, TapDownDetails(globalPosition: lastPosition)));
invokeCallback<void>('onLongTapDown', () {
onLongTapDown(
pointer,
TapDownDetails(
globalPosition: lastPosition,
kind: getKindForPointer(pointer),
),
);
});
}
@override
......
......@@ -64,7 +64,7 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT
/// by providing the optional [kind] argument. If [kind] is null,
/// the recognizer will accept pointer events from all device kinds.
/// {@endtemplate}
GestureRecognizer({ this.debugOwner, PointerDeviceKind kind }) : _kind = kind;
GestureRecognizer({ this.debugOwner, PointerDeviceKind kind }) : _kindFilter = kind;
/// The recognizer's owner.
///
......@@ -74,7 +74,11 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT
/// The kind of device that's allowed to be recognized. If null, events from
/// all device kinds will be tracked and recognized.
final PointerDeviceKind _kind;
final PointerDeviceKind _kindFilter;
/// Holds a mapping between pointer IDs and the kind of devices they are
/// coming from.
final Map<int, PointerDeviceKind> _pointerToKind = <int, PointerDeviceKind>{};
/// Registers a new pointer that might be relevant to this gesture
/// detector.
......@@ -92,6 +96,7 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT
/// This method is called for each and all pointers being added. In
/// most cases, you want to override [addAllowedPointer] instead.
void addPointer(PointerDownEvent event) {
_pointerToKind[event.pointer] = event.kind;
if (isPointerAllowed(event)) {
addAllowedPointer(event);
} else {
......@@ -123,7 +128,17 @@ abstract class GestureRecognizer extends GestureArenaMember with DiagnosticableT
bool isPointerAllowed(PointerDownEvent event) {
// Currently, it only checks for device kind. But in the future we could check
// for other things e.g. mouse button.
return _kind == null || _kind == event.kind;
return _kindFilter == null || _kindFilter == event.kind;
}
/// For a given pointer ID, returns the device kind associated with it.
///
/// The pointer ID is expected to be a valid one i.e. an event was received
/// with that pointer ID.
@protected
PointerDeviceKind getKindForPointer(int pointer) {
assert(_pointerToKind.containsKey(pointer));
return _pointerToKind[pointer];
}
/// Releases any resources used by the object.
......@@ -411,7 +426,7 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni
primaryPointer = event.pointer;
initialPosition = event.position;
if (deadline != null)
_timer = Timer(deadline, didExceedDeadline);
_timer = Timer(deadline, () => didExceedDeadlineWithEvent(event));
}
}
......@@ -444,12 +459,23 @@ abstract class PrimaryPointerGestureRecognizer extends OneSequenceGestureRecogni
/// Override to be notified when [deadline] is exceeded.
///
/// You must override this method if you supply a [deadline].
/// You must override this method or [didExceedDeadlineWithEvent] if you
/// supply a [deadline].
@protected
void didExceedDeadline() {
assert(deadline == null);
}
/// Same as [didExceedDeadline] but receives the [event] that initiated the
/// gesture.
///
/// You must override this method or [didExceedDeadline] if you supply a
/// [deadline].
@protected
void didExceedDeadlineWithEvent(PointerDownEvent event) {
didExceedDeadline();
}
@override
void acceptGesture(int pointer) {
_gestureAccepted = true;
......
......@@ -19,11 +19,16 @@ class TapDownDetails {
/// Creates details for a [GestureTapDownCallback].
///
/// The [globalPosition] argument must not be null.
TapDownDetails({ this.globalPosition = Offset.zero })
: assert(globalPosition != null);
TapDownDetails({
this.globalPosition = Offset.zero,
this.kind,
}) : assert(globalPosition != null);
/// The global position at which the pointer contacted the screen.
final Offset globalPosition;
/// The kind of the device that initiated the event.
final PointerDeviceKind kind;
}
/// Signature for when a pointer that might cause a tap has contacted the
......@@ -198,15 +203,15 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer {
}
@override
void didExceedDeadline() {
_checkDown();
void didExceedDeadlineWithEvent(PointerDownEvent event) {
_checkDown(event.pointer);
}
@override
void acceptGesture(int pointer) {
super.acceptGesture(pointer);
if (pointer == primaryPointer) {
_checkDown();
_checkDown(pointer);
_wonArenaForPrimaryPointer = true;
_checkUp();
}
......@@ -224,10 +229,15 @@ class TapGestureRecognizer extends PrimaryPointerGestureRecognizer {
}
}
void _checkDown() {
void _checkDown(int pointer) {
if (!_sentTapDown) {
if (onTapDown != null)
invokeCallback<void>('onTapDown', () { onTapDown(TapDownDetails(globalPosition: initialPosition)); });
invokeCallback<void>('onTapDown', () {
onTapDown(TapDownDetails(
globalPosition: initialPosition,
kind: getKindForPointer(pointer),
));
});
_sentTapDown = true;
}
}
......
......@@ -513,6 +513,8 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
&& widget.decoration != null
&& widget.decoration.counterText == null;
bool _shouldShowSelectionToolbar = true;
InputDecoration _getEffectiveDecoration() {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
final ThemeData themeData = Theme.of(context);
......@@ -605,17 +607,41 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
super.dispose();
}
EditableTextState get _editableText => _editableTextKey.currentState;
void _requestKeyboard() {
_editableTextKey.currentState?.requestKeyboard();
_editableText?.requestKeyboard();
}
bool _shouldShowSelectionHandles(SelectionChangedCause cause) {
// When the text field is activated by something that doesn't trigger the
// selection overlay, we shouldn't show the handles either.
if (!_shouldShowSelectionToolbar)
return false;
if (cause == SelectionChangedCause.keyboard)
return false;
if (cause == SelectionChangedCause.longPress)
return true;
if (_effectiveController.text.isNotEmpty)
return true;
return false;
}
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) {
// iOS cursor doesn't move via a selection handle. The scroll happens
// directly from new text selection changes.
if (_shouldShowSelectionHandles(cause)) {
_editableText?.showHandles();
}
switch (Theme.of(context).platform) {
case TargetPlatform.iOS:
if (cause == SelectionChangedCause.longPress) {
_editableTextKey.currentState?.bringIntoView(selection.base);
_editableText?.bringIntoView(selection.base);
}
return;
case TargetPlatform.android:
......@@ -624,6 +650,13 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
}
}
/// Toggle the toolbar when a selection handle is tapped.
void _handleSelectionHandleTapped() {
if (_effectiveController.selection.isCollapsed) {
_editableText.toggleToolbar();
}
}
InteractiveInkFeature _createInkFeature(Offset globalPosition) {
final MaterialInkController inkController = Material.of(context);
final ThemeData themeData = Theme.of(context);
......@@ -663,6 +696,16 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
void _handleTapDown(TapDownDetails details) {
_renderEditable.handleTapDown(details);
_startSplash(details.globalPosition);
// The selection overlay should only be shown when the user is interacting
// through a touch screen (via either a finger or a stylus). A mouse shouldn't
// trigger the selection overlay.
// For backwards-compatibility, we treat a null kind the same as touch.
final PointerDeviceKind kind = details.kind;
_shouldShowSelectionToolbar =
kind == null ||
kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus;
}
void _handleForcePressStarted(ForcePressDetails details) {
......@@ -671,7 +714,8 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
from: details.globalPosition,
cause: SelectionChangedCause.forcePress,
);
_editableTextKey.currentState.showToolbar();
if (_shouldShowSelectionToolbar)
_editableTextKey.currentState.showToolbar();
}
}
......@@ -738,13 +782,16 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
}
void _handleSingleLongTapEnd(LongPressEndDetails details) {
_editableTextKey.currentState.showToolbar();
print('long tap end');
if (_shouldShowSelectionToolbar)
_editableTextKey.currentState.showToolbar();
}
void _handleDoubleTapDown(TapDownDetails details) {
if (widget.selectionEnabled) {
_renderEditable.selectWord(cause: SelectionChangedCause.doubleTap);
_editableTextKey.currentState.showToolbar();
if (_shouldShowSelectionToolbar)
_editableTextKey.currentState.showToolbar();
}
}
......@@ -884,6 +931,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
onSelectionChanged: _handleSelectionChanged,
onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted,
onSelectionHandleTapped: _handleSelectionHandleTapped,
inputFormatters: formatters,
rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth,
......
......@@ -290,6 +290,7 @@ class EditableText extends StatefulWidget {
this.onEditingComplete,
this.onSubmitted,
this.onSelectionChanged,
this.onSelectionHandleTapped,
List<TextInputFormatter> inputFormatters,
this.rendererIgnoresPointer = false,
this.cursorWidth = 2.0,
......@@ -645,6 +646,9 @@ class EditableText extends StatefulWidget {
/// location).
final SelectionChangedCallback onSelectionChanged;
/// {@macro flutter.widgets.textSelection.onSelectionHandleTapped}
final VoidCallback onSelectionHandleTapped;
/// {@template flutter.widgets.editableText.inputFormatters}
/// Optional input validation and formatting overrides.
///
......@@ -1135,10 +1139,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
selectionControls: widget.selectionControls,
selectionDelegate: this,
dragStartBehavior: widget.dragStartBehavior,
onSelectionHandleTapped: widget.onSelectionHandleTapped,
);
final bool longPress = cause == SelectionChangedCause.longPress;
if (cause != SelectionChangedCause.keyboard && (_value.text.isNotEmpty || longPress))
_selectionOverlay.showHandles();
if (widget.onSelectionChanged != null)
widget.onSelectionChanged(selection, cause);
}
......@@ -1381,6 +1384,22 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_selectionOverlay?.hide();
}
/// Toggles the visibility of the toolbar.
void toggleToolbar() {
assert(_selectionOverlay != null);
if (_selectionOverlay.toolbarIsVisible) {
hideToolbar();
} else {
showToolbar();
}
}
/// Shows the handles at the location of the current selection.
void showHandles() {
assert(_selectionOverlay != null);
_selectionOverlay.showHandles();
}
VoidCallback _semanticsOnCopy(TextSelectionControls controls) {
return widget.selectionEnabled && _hasFocus && controls?.canCopy(this) == true
? () => controls.handleCopy(this)
......
......@@ -263,6 +263,7 @@ class TextSelectionOverlay {
this.selectionControls,
this.selectionDelegate,
this.dragStartBehavior = DragStartBehavior.start,
this.onSelectionHandleTapped,
}) : assert(value != null),
assert(context != null),
_value = value {
......@@ -316,6 +317,14 @@ class TextSelectionOverlay {
/// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors.
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.textSelection.onSelectionHandleTapped}
/// A callback that's invoked when a selection handle is tapped.
///
/// Both regular taps and long presses invoke this callback, but a drag
/// gesture won't.
/// {@endtemplate}
final VoidCallback onSelectionHandleTapped;
/// Controls the fade-in and fade-out animations for the toolbar and handles.
static const Duration fadeDuration = Duration(milliseconds: 150);
......@@ -393,17 +402,26 @@ class TextSelectionOverlay {
/// Whether the toolbar is currently visible.
bool get toolbarIsVisible => _toolbar != null;
/// Hides the overlay.
/// Hides the entire overlay including the toolbar and the handles.
void hide() {
if (_handles != null) {
_handles[0].remove();
_handles[1].remove();
_handles = null;
}
_toolbar?.remove();
_toolbar = null;
if (_toolbar != null) {
hideToolbar();
}
}
/// Hides the toolbar part of the overlay.
///
/// To hide the whole overlay, see [hide].
void hideToolbar() {
assert(_toolbar != null);
_toolbarController.stop();
_toolbar.remove();
_toolbar = null;
}
/// Final cleanup.
......@@ -418,7 +436,7 @@ class TextSelectionOverlay {
return Container(); // hide the second handle when collapsed
return _TextSelectionHandleOverlay(
onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); },
onSelectionHandleTapped: _handleSelectionHandleTapped,
onSelectionHandleTapped: onSelectionHandleTapped,
layerLink: layerLink,
renderObject: renderObject,
selection: _selection,
......@@ -476,17 +494,6 @@ class TextSelectionOverlay {
selectionDelegate.textEditingValue = _value.copyWith(selection: newSelection, composing: TextRange.empty);
selectionDelegate.bringIntoView(textPosition);
}
void _handleSelectionHandleTapped() {
if (_value.selection.isCollapsed) {
if (_toolbar != null) {
_toolbar?.remove();
_toolbar = null;
} else {
showToolbar();
}
}
}
}
/// This widget represents a single draggable text selection handle.
......@@ -602,7 +609,8 @@ class _TextSelectionHandleOverlayState
}
void _handleTap() {
widget.onSelectionHandleTapped();
if (widget.onSelectionHandleTapped != null)
widget.onSelectionHandleTapped();
}
@override
......
......@@ -2008,6 +2008,195 @@ void main() {
await gesture.removePointer();
});
testWidgets('Tap does not show handles nor toolbar', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(controller: controller),
),
),
);
// Tap to trigger the text field.
await tester.tap(find.byType(CupertinoTextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
});
testWidgets('Long press shows toolbar but not handles', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(controller: controller),
),
),
);
// Long press to trigger the text field.
await tester.longPress(find.byType(CupertinoTextField));
await tester.pump();
// A long press in Cupertino should position the cursor without any selection.
expect(controller.selection.isCollapsed, isTrue);
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
});
testWidgets(
'Double tap shows handles and toolbar if selection is not collapsed',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(controller: controller),
),
),
);
final Offset hPos = textOffsetToPosition(tester, 9); // Position of 'h'.
// Double tap on 'h' to select 'ghi'.
await tester.tapAt(hPos);
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(hPos);
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
},
);
testWidgets(
'Double tap shows toolbar but not handles if selection is collapsed',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(controller: controller),
),
),
);
final Offset textEndPos = textOffsetToPosition(tester, 11); // Position at the end of text.
// Double tap to place the cursor at the end.
await tester.tapAt(textEndPos);
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textEndPos);
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
},
);
testWidgets(
'Mouse long press does not show handles nor toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(controller: controller),
),
),
);
// Long press to trigger the text field.
final Offset textFieldPos = tester.getCenter(find.byType(CupertinoTextField));
final TestGesture gesture = await tester.startGesture(
textFieldPos,
kind: PointerDeviceKind.mouse,
);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
},
);
testWidgets(
'Mouse double tap does not show handles nor toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
CupertinoApp(
home: Center(
child: CupertinoTextField(controller: controller),
),
),
);
final EditableTextState editableText = tester.state(find.byType(EditableText));
// Double tap at the end of text.
final Offset textEndPos = textOffsetToPosition(tester, 11); // Position at the end of text.
TestGesture gesture = await tester.startGesture(
textEndPos,
kind: PointerDeviceKind.mouse,
);
await tester.pump(const Duration(milliseconds: 50));
await gesture.up();
await tester.pump();
await gesture.down(textEndPos);
await tester.pump();
await gesture.up();
await tester.pump();
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
final Offset hPos = textOffsetToPosition(tester, 9); // Position of 'h'.
// Double tap on 'h' to select 'ghi'.
gesture = await tester.startGesture(
hPos,
kind: PointerDeviceKind.mouse,
);
await tester.pump(const Duration(milliseconds: 50));
await gesture.up();
await tester.pump();
await gesture.down(hPos);
await tester.pump();
await gesture.up();
await tester.pump();
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
},
);
testWidgets(
'text field respects theme',
(WidgetTester tester) async {
......
......@@ -151,7 +151,7 @@ void main() {
expect(tap.toString(), equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: ready)'));
const PointerEvent event = PointerDownEvent(pointer: 1, position: Offset(10.0, 10.0));
tap.addPointer(event);
tap.didExceedDeadline();
tap.didExceedDeadlineWithEvent(event);
expect(tap.toString(), equalsIgnoringHashCodes('TapGestureRecognizer#00000(state: possible, sent tap down)'));
});
}
......@@ -5909,8 +5909,9 @@ void main() {
),
);
final RenderEditable renderEditable =
tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
await tester.tapAt(const Offset(20, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
......@@ -5961,4 +5962,281 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets('Tap shows handles but not toolbar', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Tap to trigger the text field.
await tester.tap(find.byType(TextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
});
testWidgets(
'Tap in empty text field does not show handles nor toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Tap to trigger the text field.
await tester.tap(find.byType(TextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
},
);
testWidgets('Long press shows handles and toolbar', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Long press to trigger the text field.
await tester.longPress(find.byType(TextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
});
testWidgets(
'Long press in empty text field shows handles and toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Tap to trigger the text field.
await tester.longPress(find.byType(TextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
},
);
testWidgets('Double tap shows handles and toolbar', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Double tap to trigger the text field.
await tester.tap(find.byType(TextField));
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.byType(TextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
});
testWidgets(
'Double tap in empty text field shows toolbar but not handles',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Double tap to trigger the text field.
await tester.tap(find.byType(TextField));
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.byType(TextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
},
);
testWidgets(
'Mouse tap does not show handles nor toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Long press to trigger the text field.
final Offset textFieldPos = tester.getCenter(find.byType(TextField));
final TestGesture gesture = await tester.startGesture(
textFieldPos,
pointer: 7,
kind: PointerDeviceKind.mouse,
);
await tester.pump();
await gesture.up();
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
},
);
testWidgets(
'Mouse long press does not show handles nor toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Long press to trigger the text field.
final Offset textFieldPos = tester.getCenter(find.byType(TextField));
final TestGesture gesture = await tester.startGesture(
textFieldPos,
pointer: 7,
kind: PointerDeviceKind.mouse,
);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
},
);
testWidgets(
'Mouse double tap does not show handles nor toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Double tap to trigger the text field.
final Offset textFieldPos = tester.getCenter(find.byType(TextField));
final TestGesture gesture = await tester.startGesture(
textFieldPos,
pointer: 7,
kind: PointerDeviceKind.mouse,
);
await tester.pump(const Duration(milliseconds: 50));
await gesture.up();
await tester.pump();
await gesture.down(textFieldPos);
await tester.pump();
await gesture.up();
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
expect(editableText.selectionOverlay.handlesAreVisible, isFalse);
},
);
testWidgets('Tapping selection handles toggles the toolbar', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'abc def ghi',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
// Tap to position the cursor and show the selection handles.
final Offset ePos = textOffsetToPosition(tester, 5); // Index of 'e'.
await tester.tapAt(ePos, pointer: 7);
final EditableTextState editableText = tester.state(find.byType(EditableText));
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
expect(editableText.selectionOverlay.handlesAreVisible, isTrue);
final RenderEditable renderEditable = findRenderEditable(tester);
final List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
renderEditable,
);
expect(endpoints.length, 1);
// Tap the handle to show the toolbar.
final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0);
await tester.tapAt(handlePos, pointer: 7);
expect(editableText.selectionOverlay.toolbarIsVisible, isTrue);
// Tap the handle again to hide the toolbar.
await tester.tapAt(handlePos, pointer: 7);
expect(editableText.selectionOverlay.toolbarIsVisible, isFalse);
});
}
......@@ -898,51 +898,6 @@ void main() {
semantics.dispose();
});
testWidgets('changing selection with keyboard does not show handles', (WidgetTester tester) async {
const String value1 = 'Hello World';
controller.text = value1;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
selectionControls: materialTextSelectionControls,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
),
);
// Simulate selection change via tap to show handles.
final RenderEditable render = tester.allRenderObjects
.firstWhere((RenderObject o) => o.runtimeType == RenderEditable);
render.onSelectionChanged(const TextSelection.collapsed(offset: 4), render,
SelectionChangedCause.tap);
await tester.pumpAndSettle();
final EditableTextState textState = tester.state(find.byType(EditableText));
expect(textState.selectionOverlay.handlesAreVisible, isTrue);
expect(
textState.selectionOverlay.selectionDelegate.textEditingValue.selection,
const TextSelection.collapsed(offset: 4),
);
// Simulate selection change via keyboard and expect handles to disappear.
render.onSelectionChanged(const TextSelection.collapsed(offset: 10), render,
SelectionChangedCause.keyboard);
await tester.pumpAndSettle();
expect(textState.selectionOverlay.handlesAreVisible, isFalse);
expect(
textState.selectionOverlay.selectionDelegate.textEditingValue.selection,
const TextSelection.collapsed(offset: 10),
);
});
testWidgets('exposes correct cursor movement semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
......@@ -2034,6 +1989,7 @@ void main() {
// Select the first word. Both handles should be visible.
await tester.tapAt(const Offset(20, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
state.showHandles();
await tester.pump();
await verifyVisibility(HandlePositionInViewport.leftEdge, true, HandlePositionInViewport.within, true);
......@@ -2055,6 +2011,7 @@ void main() {
// Now that the second word has been dragged fully into view, select it.
await tester.tapAt(const Offset(80, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
state.showHandles();
await tester.pump();
await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.within, true);
......@@ -2099,6 +2056,7 @@ void main() {
// Select the first word. Both handles should be visible.
await tester.tapAt(const Offset(20, 10));
state.renderEditable.selectWord(cause: SelectionChangedCause.longPress);
state.showHandles();
await tester.pump();
final List<Positioned> positioned =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList();
......@@ -2218,6 +2176,7 @@ void main() {
// Select the first word. Both handles should be visible.
await tester.tapAt(const Offset(20, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
state.showHandles();
await tester.pump();
await verifyVisibility(HandlePositionInViewport.leftEdge, true, HandlePositionInViewport.within, true);
......@@ -2239,6 +2198,7 @@ void main() {
// Now that the second word has been dragged fully into view, select it.
await tester.tapAt(const Offset(80, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
state.showHandles();
await tester.pump();
await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.within, true);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment