blob: 0776562880dd91e12dd79c63a072ee78f5f4f519 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// reduced-test-set:
// This file is run as part of a reduced test set in CI on Mac and Windows
// machines.
// no-shuffle:
// TODO(122950): Remove this tag once this test's state leaks/test
// dependencies have been fixed.
// https://github.com/flutter/flutter/issues/122950
// Fails with "flutter test --test-randomize-ordering-seed=20230318"
@Tags(<String>['reduced-test-set', 'no-shuffle'])
library;
import 'dart:math' as math;
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart';
import '../widgets/live_text_utils.dart';
import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
typedef FormatEditUpdateCallback = void Function(TextEditingValue, TextEditingValue);
// On web, key events in text fields are handled by the browser.
const bool areKeyEventsHandledByPlatform = isBrowser;
class CupertinoLocalizationsDelegate extends LocalizationsDelegate<CupertinoLocalizations> {
@override
bool isSupported(Locale locale) => true;
@override
Future<CupertinoLocalizations> load(Locale locale) =>
DefaultCupertinoLocalizations.load(locale);
@override
bool shouldReload(CupertinoLocalizationsDelegate old) => false;
}
class MaterialLocalizationsDelegate extends LocalizationsDelegate<MaterialLocalizations> {
@override
bool isSupported(Locale locale) => true;
@override
Future<MaterialLocalizations> load(Locale locale) => DefaultMaterialLocalizations.load(locale);
@override
bool shouldReload(MaterialLocalizationsDelegate old) => false;
}
class WidgetsLocalizationsDelegate extends LocalizationsDelegate<WidgetsLocalizations> {
@override
bool isSupported(Locale locale) => true;
@override
Future<WidgetsLocalizations> load(Locale locale) => DefaultWidgetsLocalizations.load(locale);
@override
bool shouldReload(WidgetsLocalizationsDelegate old) => false;
}
Widget overlay({ required Widget child }) {
final OverlayEntry entry = OverlayEntry(
builder: (BuildContext context) {
return Center(
child: Material(
child: child,
),
);
},
);
return overlayWithEntry(entry);
}
Widget overlayWithEntry(OverlayEntry entry) {
return Localizations(
locale: const Locale('en', 'US'),
delegates: <LocalizationsDelegate<dynamic>>[
WidgetsLocalizationsDelegate(),
MaterialLocalizationsDelegate(),
CupertinoLocalizationsDelegate(),
],
child: DefaultTextEditingShortcuts(
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(size: Size(800.0, 600.0)),
child: Overlay(
initialEntries: <OverlayEntry>[
entry,
],
),
),
),
),
);
}
Widget boilerplate({ required Widget child, ThemeData? theme }) {
return MaterialApp(
theme: theme,
home: Localizations(
locale: const Locale('en', 'US'),
delegates: <LocalizationsDelegate<dynamic>>[
WidgetsLocalizationsDelegate(),
MaterialLocalizationsDelegate(),
],
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(size: Size(800.0, 600.0)),
child: Center(
child: Material(
child: child,
),
),
),
),
),
);
}
Future<void> skipPastScrollingAnimation(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
}
double getOpacity(WidgetTester tester, Finder finder) {
return tester.widget<FadeTransition>(
find.ancestor(
of: finder,
matching: find.byType(FadeTransition),
),
).opacity.value;
}
class TestFormatter extends TextInputFormatter {
TestFormatter(this.onFormatEditUpdate);
FormatEditUpdateCallback onFormatEditUpdate;
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
onFormatEditUpdate(oldValue, newValue);
return newValue;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final MockClipboard mockClipboard = MockClipboard();
const String kThreeLines =
'First line of text is\n'
'Second line goes until\n'
'Third line of stuff';
const String kMoreThanFourLines =
'$kThreeLines\n'
"Fourth line won't display and ends at";
// Gap between caret and edge of input, defined in editable.dart.
const int kCaretGap = 1;
setUp(() async {
debugResetSemanticsIdCounter();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
mockClipboard.handleMethodCall,
);
// Fill the clipboard so that the Paste option is available in the text
// selection menu.
await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
);
});
final Key textFieldKey = UniqueKey();
Widget textFieldBuilder({
int? maxLines = 1,
int? minLines,
}) {
return boilerplate(
child: TextField(
key: textFieldKey,
style: const TextStyle(color: Colors.black, fontSize: 34.0),
maxLines: maxLines,
minLines: minLines,
decoration: const InputDecoration(
hintText: 'Placeholder',
),
),
);
}
testWidgets(
'Live Text button shows and hides correctly when LiveTextStatus changes',
(WidgetTester tester) async {
final LiveTextInputTester liveTextInputTester = LiveTextInputTester();
addTearDown(liveTextInputTester.dispose);
final TextEditingController controller = TextEditingController(text: '');
const Key key = ValueKey<String>('TextField');
final FocusNode focusNode = FocusNode();
final Widget app = MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Scaffold(
body: Center(
child: TextField(
key: key,
controller: controller,
focusNode: focusNode,
),
),
),
);
liveTextInputTester.mockLiveTextInputEnabled = true;
await tester.pumpWidget(app);
focusNode.requestFocus();
await tester.pumpAndSettle();
final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
await tester.pumpAndSettle();
expect(
findLiveTextButton(),
kIsWeb ? findsNothing : findsOneWidget,
);
liveTextInputTester.mockLiveTextInputEnabled = false;
await tester.longPress(textFinder);
await tester.pumpAndSettle();
expect(findLiveTextButton(), findsNothing);
},
);
testWidgets('text field selection toolbar should hide when the user starts typing', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 100,
height: 100,
child: TextField(
decoration: InputDecoration(hintText: 'Placeholder'),
),
),
),
),
),
);
await tester.showKeyboard(find.byType(TextField));
const String testValue = 'A B C';
tester.testTextInput.updateEditingValue(
const TextEditingValue(
text: testValue,
),
);
await tester.pump();
// The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar.
// (This is true even if we provide selection parameter to the TextEditingValue above.)
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap);
expect(state.showToolbar(), true);
// This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible.
await tester.pumpAndSettle();
// Sanity check that the toolbar widget exists.
expect(find.text('Paste'), findsOneWidget);
const String newValue = 'A B C D';
tester.testTextInput.updateEditingValue(
const TextEditingValue(
text: newValue,
),
);
await tester.pump();
expect(state.selectionOverlay!.toolbarIsVisible, isFalse);
}, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu.
testWidgets('Composing change does not hide selection handle caret', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/108673
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
),
),
);
const String testValue = 'I Love Flutter!';
await tester.enterText(find.byType(TextField), testValue);
expect(controller.value.text, testValue);
await skipPastScrollingAnimation(tester);
// Handle not shown.
expect(controller.selection.isCollapsed, true);
final Finder fadeFinder = find.byType(FadeTransition);
FadeTransition handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(0.0));
// Tap on the text field to show the handle.
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
expect(fadeFinder, findsNWidgets(1));
handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(1.0));
final RenderObject handleRenderObjectBegin = tester.renderObject(fadeFinder.at(0));
expect(
controller.value,
const TextEditingValue(
text: 'I Love Flutter!',
selection: TextSelection.collapsed(offset: 15, affinity: TextAffinity.upstream),
),
);
// Simulate text composing change.
tester.testTextInput.updateEditingValue(
controller.value.copyWith(
composing: const TextRange(start: 7, end: 15),
),
);
await skipPastScrollingAnimation(tester);
expect(
controller.value,
const TextEditingValue(
text: 'I Love Flutter!',
selection: TextSelection.collapsed(offset: 15, affinity: TextAffinity.upstream),
composing: TextRange(start: 7, end: 15),
),
);
// Handle still shown.
expect(controller.selection.isCollapsed, true);
handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(1.0));
// Simulate text composing and affinity change.
tester.testTextInput.updateEditingValue(
controller.value.copyWith(
selection: controller.value.selection.copyWith(affinity: TextAffinity.downstream),
composing: const TextRange(start: 8, end: 15),
),
);
await skipPastScrollingAnimation(tester);
expect(
controller.value,
const TextEditingValue(
text: 'I Love Flutter!',
selection: TextSelection.collapsed(offset: 15, affinity: TextAffinity.upstream),
composing: TextRange(start: 8, end: 15),
),
);
// Handle still shown.
expect(controller.selection.isCollapsed, true);
handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(1.0));
final RenderObject handleRenderObjectEnd = tester.renderObject(fadeFinder.at(0));
// The RenderObject sub-tree should not be unmounted.
expect(identical(handleRenderObjectBegin, handleRenderObjectEnd), true);
});
testWidgets('can use the desktop cut/copy/paste buttons on Mac', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'blah1 blah2',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
// Initially, the menu is not shown and there is no selection.
expect(find.byType(CupertinoButton), findsNothing);
expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1));
final Offset midBlah1 = textOffsetToPosition(tester, 2);
// Right clicking shows the menu.
final TestGesture gesture = await tester.startGesture(
midBlah1,
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
// Copy the first word.
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2');
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.byType(CupertinoButton), findsNothing);
// Paste it at the end.
await gesture.down(textOffsetToPosition(tester, controller.text.length));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Paste'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection.collapsed(offset: 16));
// Cut the first word.
await gesture.down(midBlah1);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
await tester.tap(find.text('Cut'));
await tester.pumpAndSettle();
expect(controller.text, ' blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0));
expect(find.byType(CupertinoButton), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS }),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets('can use the desktop cut/copy/paste buttons on Windows and Linux', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'blah1 blah2',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
),
),
),
);
// Initially, the menu is not shown and there is no selection.
expect(find.byType(CupertinoButton), findsNothing);
expect(controller.selection, const TextSelection(baseOffset: -1, extentOffset: -1));
final Offset midBlah1 = textOffsetToPosition(tester, 2);
// Right clicking shows the menu.
TestGesture gesture = await tester.startGesture(
midBlah1,
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await gesture.removePointer();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection.collapsed(offset: 2));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
expect(find.text('Select all'), findsOneWidget);
// Double tap to select the first word, then right click to show the menu.
final Offset startBlah1 = textOffsetToPosition(tester, 0);
gesture = await tester.startGesture(
startBlah1,
kind: PointerDeviceKind.mouse,
);
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 100));
await gesture.down(startBlah1);
await tester.pump();
await gesture.up();
await gesture.removePointer();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select all'), findsNothing);
gesture = await tester.startGesture(
midBlah1,
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await gesture.removePointer();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
// Copy the first word.
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2');
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.byType(CupertinoButton), findsNothing);
// Paste it at the end.
gesture = await tester.startGesture(
textOffsetToPosition(tester, controller.text.length),
kind: PointerDeviceKind.mouse,
);
await tester.pump();
await gesture.up();
await gesture.removePointer();
expect(controller.selection, const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream));
gesture = await tester.startGesture(
textOffsetToPosition(tester, controller.text.length),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await gesture.removePointer();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection.collapsed(offset: 11, affinity: TextAffinity.upstream));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Paste'));
await tester.pumpAndSettle();
expect(controller.text, 'blah1 blah2blah1');
expect(controller.selection, const TextSelection.collapsed(offset: 16));
// Cut the first word.
gesture = await tester.startGesture(
midBlah1,
kind: PointerDeviceKind.mouse,
);
await tester.pump();
await gesture.up();
await tester.pump(const Duration(milliseconds: 100));
await gesture.down(startBlah1);
await tester.pump();
await gesture.up();
await gesture.removePointer();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
expect(find.text('Paste'), findsNothing);
expect(find.text('Select all'), findsNothing);
gesture = await tester.startGesture(
textOffsetToPosition(tester, controller.text.length),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await tester.pump();
await gesture.up();
await gesture.removePointer();
await tester.pumpAndSettle();
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 5));
expect(find.text('Cut'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Cut'));
await tester.pumpAndSettle();
expect(controller.text, ' blah2blah1');
expect(controller.selection, const TextSelection(baseOffset: 0, extentOffset: 0));
expect(find.byType(CupertinoButton), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.windows }),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets('uses DefaultSelectionStyle for selection and cursor colors if provided', (WidgetTester tester) async {
const Color selectionColor = Colors.orange;
const Color cursorColor = Colors.red;
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: DefaultSelectionStyle(
selectionColor: selectionColor,
cursorColor: cursorColor,
child: TextField(autofocus: true),
),
),
),
);
await tester.pump();
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
expect(state.widget.selectionColor, selectionColor);
expect(state.widget.cursorColor, cursorColor);
});
testWidgets('sets cursorOpacityAnimates on EditableText correctly', (WidgetTester tester) async {
// True
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: TextField(autofocus: true, cursorOpacityAnimates: true),
),
),
);
await tester.pump();
EditableText editableText = tester.widget(find.byType(EditableText));
expect(editableText.cursorOpacityAnimates, true);
// False
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: TextField(autofocus: true, cursorOpacityAnimates: false),
),
),
);
await tester.pump();
editableText = tester.widget(find.byType(EditableText));
expect(editableText.cursorOpacityAnimates, false);
});
testWidgets('Activates the text field when receives semantics focus on desktops', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!;
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(focusNode: focusNode),
),
),
);
expect(semantics, hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[SemanticsFlag.isTextField],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.didGainAccessibilityFocus,
SemanticsAction.didLoseAccessibilityFocus,
],
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
ignoreRect: true,
ignoreTransform: true,
));
expect(focusNode.hasFocus, isFalse);
semanticsOwner.performAction(4, SemanticsAction.didGainAccessibilityFocus);
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
semanticsOwner.performAction(4, SemanticsAction.didLoseAccessibilityFocus);
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isFalse);
semantics.dispose();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.macOS, TargetPlatform.windows, TargetPlatform.linux }));
testWidgets('TextField passes onEditingComplete to EditableText', (WidgetTester tester) async {
void onEditingComplete() { }
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
onEditingComplete: onEditingComplete,
),
),
),
);
final Finder editableTextFinder = find.byType(EditableText);
expect(editableTextFinder, findsOneWidget);
final EditableText editableTextWidget = tester.widget(editableTextFinder);
expect(editableTextWidget.onEditingComplete, onEditingComplete);
});
testWidgets('TextField has consistent size', (WidgetTester tester) async {
final Key textFieldKey = UniqueKey();
String? textFieldValue;
await tester.pumpWidget(
overlay(
child: TextField(
key: textFieldKey,
decoration: const InputDecoration(
hintText: 'Placeholder',
),
onChanged: (String value) {
textFieldValue = value;
},
),
),
);
RenderBox findTextFieldBox() => tester.renderObject(find.byKey(textFieldKey));
final RenderBox inputBox = findTextFieldBox();
final Size emptyInputSize = inputBox.size;
Future<void> checkText(String testValue) async {
return TestAsyncUtils.guard(() async {
expect(textFieldValue, isNull);
await tester.enterText(find.byType(TextField), testValue);
// Check that the onChanged event handler fired.
expect(textFieldValue, equals(testValue));
textFieldValue = null;
await skipPastScrollingAnimation(tester);
});
}
await checkText(' ');
expect(findTextFieldBox(), equals(inputBox));
expect(inputBox.size, equals(emptyInputSize));
await checkText('Test');
expect(findTextFieldBox(), equals(inputBox));
expect(inputBox.size, equals(emptyInputSize));
});
testWidgets('Cursor blinks', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(
child: const TextField(
decoration: InputDecoration(
hintText: 'Placeholder',
),
),
),
);
await tester.showKeyboard(find.byType(TextField));
final EditableTextState editableText = tester.state(find.byType(EditableText));
// Check that the cursor visibility toggles after each blink interval.
Future<void> checkCursorToggle() async {
final bool initialShowCursor = editableText.cursorCurrentlyVisible;
await tester.pump(editableText.cursorBlinkInterval);
expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor));
await tester.pump(editableText.cursorBlinkInterval);
expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
await tester.pump(editableText.cursorBlinkInterval ~/ 10);
expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
await tester.pump(editableText.cursorBlinkInterval);
expect(editableText.cursorCurrentlyVisible, equals(!initialShowCursor));
await tester.pump(editableText.cursorBlinkInterval);
expect(editableText.cursorCurrentlyVisible, equals(initialShowCursor));
}
await checkCursorToggle();
await tester.showKeyboard(find.byType(TextField));
// Try the test again with a nonempty EditableText.
tester.testTextInput.updateEditingValue(const TextEditingValue(
text: 'X',
selection: TextSelection.collapsed(offset: 1),
));
await tester.idle();
expect(tester.state(find.byType(EditableText)), editableText);
await checkCursorToggle();
});
// Regression test for https://github.com/flutter/flutter/issues/78918.
testWidgets('RenderEditable sets correct text editing value', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'how are you');
final UniqueKey icon = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
controller: controller,
decoration: InputDecoration(
suffixIcon: IconButton(
key: icon,
icon: const Icon(Icons.cancel),
onPressed: () => controller.clear(),
),
),
),
),
),
);
await tester.tap(find.byKey(icon));
await tester.pump();
expect(controller.text, '');
expect(controller.selection, const TextSelection.collapsed(offset: 0));
});
testWidgets('Cursor radius is 2.0', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: TextField(),
),
),
);
final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
final RenderEditable renderEditable = editableTextState.renderEditable;
expect(renderEditable.cursorRadius, const Radius.circular(2.0));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('cursor has expected defaults', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(
child: const TextField(),
),
);
final TextField textField = tester.firstWidget(find.byType(TextField));
expect(textField.cursorWidth, 2.0);
expect(textField.cursorHeight, null);
expect(textField.cursorRadius, null);
});
testWidgets('cursor has expected radius value', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(
child: const TextField(
cursorRadius: Radius.circular(3.0),
),
),
);
final TextField textField = tester.firstWidget(find.byType(TextField));
expect(textField.cursorWidth, 2.0);
expect(textField.cursorRadius, const Radius.circular(3.0));
});
testWidgets('clipBehavior has expected defaults', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(
child: const TextField(),
),
);
final TextField textField = tester.firstWidget(find.byType(TextField));
expect(textField.clipBehavior, Clip.hardEdge);
});
testWidgets('Overflow clipBehavior none golden', (WidgetTester tester) async {
final Widget widget = Theme(
data: ThemeData(useMaterial3: false),
child: overlay(
child: RepaintBoundary(
key: const ValueKey<int>(1),
child: SizedBox(
height: 200,
width: 200,
child: Center(
child: SizedBox(
// Make sure the input field is not high enough for the WidgetSpan.
height: 50,
child: TextField(
controller: OverflowWidgetTextEditingController(),
clipBehavior: Clip.none,
),
),
),
),
),
),
);
await tester.pumpWidget(widget);
final TextField textField = tester.firstWidget(find.byType(TextField));
expect(textField.clipBehavior, Clip.none);
final EditableText editableText = tester.firstWidget(find.byType(EditableText));
expect(editableText.clipBehavior, Clip.none);
await expectLater(
find.byKey(const ValueKey<int>(1)),
matchesGoldenFile('overflow_clipbehavior_none.material.0.png'),
);
});
testWidgets('Material cursor android golden', (WidgetTester tester) async {
final Widget widget = Theme(
data: ThemeData(useMaterial3: false),
child: overlay(
child: const RepaintBoundary(
key: ValueKey<int>(1),
child: TextField(
cursorColor: Colors.blue,
cursorWidth: 15,
cursorRadius: Radius.circular(3.0),
),
),
),
);
await tester.pumpWidget(widget);
const String testValue = 'A short phrase';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
await tester.tapAt(textOffsetToPosition(tester, testValue.length));
await tester.pump();
await expectLater(
find.byKey(const ValueKey<int>(1)),
matchesGoldenFile('text_field_cursor_test.material.0.png'),
);
});
testWidgets('Material cursor golden', (WidgetTester tester) async {
final Widget widget = Theme(
data: ThemeData(useMaterial3: false),
child: overlay(
child: const RepaintBoundary(
key: ValueKey<int>(1),
child: TextField(
cursorColor: Colors.blue,
cursorWidth: 15,
cursorRadius: Radius.circular(3.0),
),
),
),
);
await tester.pumpWidget(widget);
const String testValue = 'A short phrase';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
await tester.tapAt(textOffsetToPosition(tester, testValue.length));
await tester.pump();
await expectLater(
find.byKey(const ValueKey<int>(1)),
matchesGoldenFile(
'text_field_cursor_test_${debugDefaultTargetPlatformOverride!.name.toLowerCase()}.material.1.png',
),
);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('TextInputFormatter gets correct selection value', (WidgetTester tester) async {
late TextEditingValue actualOldValue;
late TextEditingValue actualNewValue;
void callBack(TextEditingValue oldValue, TextEditingValue newValue) {
actualOldValue = oldValue;
actualNewValue = newValue;
}
final FocusNode focusNode = FocusNode();
final TextEditingController controller = TextEditingController(text: '123');
await tester.pumpWidget(
boilerplate(
child: TextField(
controller: controller,
focusNode: focusNode,
inputFormatters: <TextInputFormatter>[TestFormatter(callBack)],
),
),
);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
expect(
actualOldValue,
const TextEditingValue(
text: '123',
selection: TextSelection.collapsed(offset: 3, affinity: TextAffinity.upstream),
),
);
expect(
actualNewValue,
const TextEditingValue(
text: '12',
selection: TextSelection.collapsed(offset: 2),
),
);
}, skip: areKeyEventsHandledByPlatform); // [intended] only applies to platforms where we handle key events.
testWidgets('text field selection toolbar renders correctly inside opacity', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: false),
home: const Scaffold(
body: Center(
child: SizedBox(
width: 100,
height: 100,
child: Opacity(
opacity: 0.5,
child: TextField(
decoration: InputDecoration(hintText: 'Placeholder'),
),
),
),
),
),
),
);
await tester.showKeyboard(find.byType(TextField));
const String testValue = 'A B C';
tester.testTextInput.updateEditingValue(
const TextEditingValue(
text: testValue,
),
);
await tester.pump();
// The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar.
// (This is true even if we provide selection parameter to the TextEditingValue above.)
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap);
expect(state.showToolbar(), true);
// This is needed for the AnimatedOpacity to turn from 0 to 1 so the toolbar is visible.
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 1));
// Sanity check that the toolbar widget exists.
expect(find.text('Paste'), findsOneWidget);
await expectLater(
// The toolbar exists in the Overlay above the MaterialApp.
find.byType(Overlay),
matchesGoldenFile('text_field_opacity_test.0.png'),
);
}, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu.
testWidgets('text field toolbar options correctly changes options',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
// On iOS/iPadOS, during a tap we select the edge of the word closest to the tap.
// On macOS, we select the precise position of the tap.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
toolbarOptions: const ToolbarOptions(copy: true),
),
),
),
),
);
// This tap just puts the cursor somewhere different than where the double
// tap will occur to test that the double tap moves the existing cursor first.
await tester.tapAt(textOffsetToPosition(tester, 3));
await tester.pump(const Duration(milliseconds: 500));
await tester.tapAt(textOffsetToPosition(tester, 8));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
expect(
controller.selection,
const TextSelection.collapsed(offset: 8),
);
await tester.tapAt(textOffsetToPosition(tester, 8));
await tester.pump();
// Second tap selects the word around the cursor.
expect(
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
// Selected text shows 'Copy', and not 'Paste', 'Cut', 'Select All'.
expect(find.text('Paste'), findsNothing);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsNothing);
expect(find.text('Select All'), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets('text selection style 1', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure\nhi\nwasssup!',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: false),
home: Material(
child: Center(
child: RepaintBoundary(
child: Container(
width: 650.0,
height: 600.0,
decoration: const BoxDecoration(
color: Color(0xff00ff00),
),
child: Column(
children: <Widget>[
TextField(
key: const Key('field0'),
controller: controller,
style: const TextStyle(height: 4, color: Colors.black45),
toolbarOptions: const ToolbarOptions(copy: true, selectAll: true),
selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingTop,
selectionWidthStyle: ui.BoxWidthStyle.max,
maxLines: 3,
),
],
),
),
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byKey(const Key('field0')));
await tester.longPressAt(textfieldStart + const Offset(50.0, 2.0));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textfieldStart + const Offset(100.0, 107.0));
await tester.pump(const Duration(milliseconds: 300));
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('text_field_golden.TextSelectionStyle.1.png'),
);
});
testWidgets('text selection style 2', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure\nhi\nwasssup!',
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: false),
home: Material(
child: Center(
child: RepaintBoundary(
child: Container(
width: 650.0,
height: 600.0,
decoration: const BoxDecoration(
color: Color(0xff00ff00),
),
child: Column(
children: <Widget>[
TextField(
key: const Key('field0'),
controller: controller,
style: const TextStyle(height: 4, color: Colors.black45),
toolbarOptions: const ToolbarOptions(copy: true, selectAll: true),
selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingBottom,
maxLines: 3,
),
],
),
),
),
),
),
),
);
final EditableTextState editableTextState = tester.state(find.byType(EditableText));
// Double tap to select the first word.
const int index = 4;
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
expect(editableTextState.selectionOverlay!.handlesAreVisible, isTrue);
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 7);
// Select all text. Use the toolbar if possible. iOS only shows the toolbar
// when the selection is collapsed.
if (isContextMenuProvidedByPlatform || defaultTargetPlatform == TargetPlatform.iOS) {
controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length);
expect(controller.selection.extentOffset, controller.text.length);
} else {
await tester.tap(find.text('Select all'));
await tester.pump();
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, controller.text.length);
}
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('text_field_golden.TextSelectionStyle.2.png'),
);
// Text selection styles are not fully supported on web.
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/93723
testWidgets(
'text field toolbar options correctly changes options',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
toolbarOptions: const ToolbarOptions(copy: true),
),
),
),
),
);
final Offset pos = textOffsetToPosition(tester, 9); // Index of 'P|eel'
await tester.tapAt(pos);
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(pos);
await tester.pump();
// Selected text shows 'Copy', and not 'Paste', 'Cut', 'Select all'.
expect(find.text('Paste'), findsNothing);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Cut'), findsNothing);
expect(find.text('Select all'), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.android,
TargetPlatform.fuchsia,
TargetPlatform.linux,
TargetPlatform.windows,
}),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
testWidgets('cursor layout has correct width', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController.fromValue(
const TextEditingValue(selection: TextSelection.collapsed(offset: 0)),
);
final FocusNode focusNode = FocusNode();
EditableText.debugDeterministicCursor = true;
await tester.pumpWidget(
Theme(
data: ThemeData(useMaterial3: false),
child: overlay(
child: RepaintBoundary(
child: TextField(
cursorWidth: 15.0,
controller: controller,
focusNode: focusNode,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await expectLater(
find.byType(TextField),
matchesGoldenFile('text_field_cursor_width_test.0.png'),
);
EditableText.debugDeterministicCursor = false;
});
testWidgets('cursor layout has correct radius', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController.fromValue(
const TextEditingValue(selection: TextSelection.collapsed(offset: 0)),
);
final FocusNode focusNode = FocusNode();
EditableText.debugDeterministicCursor = true;
await tester.pumpWidget(
Theme(
data: ThemeData(useMaterial3: false),
child: overlay(
child: RepaintBoundary(
child: TextField(
cursorWidth: 15.0,
cursorRadius: const Radius.circular(3.0),
controller: controller,
focusNode: focusNode,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await expectLater(
find.byType(TextField),
matchesGoldenFile('text_field_cursor_width_test.1.png'),
);
EditableText.debugDeterministicCursor = false;
});
testWidgets('cursor layout has correct height', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController.fromValue(
const TextEditingValue(selection: TextSelection.collapsed(offset: 0)),
);
final FocusNode focusNode = FocusNode();
EditableText.debugDeterministicCursor = true;
await tester.pumpWidget(
Theme(
data: ThemeData(useMaterial3: false),
child: overlay(
child: RepaintBoundary(
child: TextField(
cursorWidth: 15.0,
cursorHeight: 30.0,
controller: controller,
focusNode: focusNode,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await expectLater(
find.byType(TextField),
matchesGoldenFile('text_field_cursor_width_test.2.png'),
);
EditableText.debugDeterministicCursor = false;
});
testWidgets('Overflowing a line with spaces stops the cursor at the end', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
Theme(
data: ThemeData(useMaterial3: false),
child: overlay(
child: TextField(
key: textFieldKey,
controller: controller,
maxLines: null,
),
),
),
);
expect(controller.selection.baseOffset, -1);
expect(controller.selection.extentOffset, -1);
const String testValueOneLine = 'enough text to be exactly at the end of the line.';
await tester.enterText(find.byType(TextField), testValueOneLine);
await skipPastScrollingAnimation(tester);
RenderBox findInputBox() => tester.renderObject(find.byKey(textFieldKey));
RenderBox inputBox = findInputBox();
final Size oneLineInputSize = inputBox.size;
await tester.tapAt(textOffsetToPosition(tester, testValueOneLine.length));
await tester.pump();
const String testValueTwoLines = 'enough text to overflow the first line and go to the second';
await tester.enterText(find.byType(TextField), testValueTwoLines);
await skipPastScrollingAnimation(tester);
expect(inputBox, findInputBox());
inputBox = findInputBox();
expect(inputBox.size.height, greaterThan(oneLineInputSize.height));
final Size twoLineInputSize = inputBox.size;
// Enter a string with the same number of characters as testValueTwoLines,
// but where the overflowing part is all spaces. Assert that it only renders
// on one line.
const String testValueSpaces = '$testValueOneLine ';
expect(testValueSpaces.length, testValueTwoLines.length);
await tester.enterText(find.byType(TextField), testValueSpaces);
await skipPastScrollingAnimation(tester);
expect(inputBox, findInputBox());
inputBox = findInputBox();
expect(inputBox.size.height, oneLineInputSize.height);
// Swapping the final space for a letter causes it to wrap to 2 lines.
const String testValueSpacesOverflow = '$testValueOneLine a';
expect(testValueSpacesOverflow.length, testValueTwoLines.length);
await tester.enterText(find.byType(TextField), testValueSpacesOverflow);
await skipPastScrollingAnimation(tester);
expect(inputBox, findInputBox());
inputBox = findInputBox();
expect(inputBox.size.height, twoLineInputSize.height);
// Positioning the cursor at the end of a line overflowing with spaces puts
// it inside the input still.
await tester.enterText(find.byType(TextField), testValueSpaces);
await skipPastScrollingAnimation(tester);
await tester.tapAt(textOffsetToPosition(tester, testValueSpaces.length));
await tester.pump();
final double inputWidth = findRenderEditable(tester).size.width;
final Offset cursorOffsetSpaces = findRenderEditable(tester).getLocalRectForCaret(
const TextPosition(offset: testValueSpaces.length),
).bottomRight;
expect(cursorOffsetSpaces.dx, inputWidth - kCaretGap);
});
testWidgets('Overflowing a line with spaces stops the cursor at the end (rtl direction)', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(
child: const TextField(
textDirection: TextDirection.rtl,
maxLines: null,
),
),
);
const String testValueOneLine = 'enough text to be exactly at the end of the line.';
const String testValueSpaces = '$testValueOneLine ';
// Positioning the cursor at the end of a line overflowing with spaces puts
// it inside the input still.
await tester.enterText(find.byType(TextField), testValueSpaces);
await skipPastScrollingAnimation(tester);
await tester.tapAt(textOffsetToPosition(tester, testValueSpaces.length));
await tester.pump();
final Offset cursorOffsetSpaces = findRenderEditable(tester).getLocalRectForCaret(
const TextPosition(offset: testValueSpaces.length),
).topLeft;
expect(cursorOffsetSpaces.dx >= 0, isTrue);
});
testWidgets('mobile obscureText control test', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(
child: const TextField(
obscureText: true,
decoration: InputDecoration(
hintText: 'Placeholder',
),
),
),
);
await tester.showKeyboard(find.byType(TextField));
const String testValue = 'ABC';
tester.testTextInput.updateEditingValue(const TextEditingValue(
text: testValue,
selection: TextSelection.collapsed(offset: testValue.length),
));
await tester.pump();
// Enter a character into the obscured field and verify that the character
// is temporarily shown to the user and then changed to a bullet.
const String newChar = 'X';
tester.testTextInput.updateEditingValue(const TextEditingValue(
text: testValue + newChar,
selection: TextSelection.collapsed(offset: testValue.length + 1),
));
await tester.pump();
String editText = (findRenderEditable(tester).text! as TextSpan).text!;
expect(editText.substring(editText.length - 1), newChar);
await tester.pump(const Duration(seconds: 2));
editText = (findRenderEditable(tester).text! as TextSpan).text!;
expect(editText.substring(editText.length - 1), '\u2022');
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android }));
testWidgets('desktop obscureText control test', (WidgetTester tester) async {
await tester.pumpWidget(
overlay(
child: const TextField(
obscureText: true,
decoration: InputDecoration(
hintText: 'Placeholder',
),
),
),
);
await tester.showKeyboard(find.byType(TextField));
const String testValue = 'ABC';
tester.testTextInput.updateEditingValue(const TextEditingValue(
text: testValue,
selection: TextSelection.collapsed(offset: testValue.length),
));
await tester.pump();
// Enter a character into the obscured field and verify that the character
// isn't shown to the user.
const String newChar = 'X';
tester.testTextInput.updateEditingValue(const TextEditingValue(
text: testValue + newChar,
selection: TextSelection.collapsed(offset: testValue.length + 1),
));
await tester.pump();
final String editText = (findRenderEditable(tester).text! as TextSpan).text!;
expect(editText.substring(editText.length - 1), '\u2022');
}, variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.macOS,
TargetPlatform.linux,
TargetPlatform.windows,
}));
testWidgets('Caret position is updated on tap', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
),
),
);
expect(controller.selection.baseOffset, -1);
expect(controller.selection.extentOffset, -1);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
// Tap to reposition the caret.
final int tapIndex = testValue.indexOf('e');
final Offset ePos = textOffsetToPosition(tester, tapIndex);
await tester.tapAt(ePos);
await tester.pump();
expect(controller.selection.baseOffset, tapIndex);
expect(controller.selection.extentOffset, tapIndex);
});
testWidgets('enableInteractiveSelection = false, tap', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
enableInteractiveSelection: false,
),
),
);
expect(controller.selection.baseOffset, -1);
expect(controller.selection.extentOffset, -1);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
// Tap would ordinarily reposition the caret.
final int tapIndex = testValue.indexOf('e');
final Offset ePos = textOffsetToPosition(tester, tapIndex);
await tester.tapAt(ePos);
await tester.pump();
expect(controller.selection.baseOffset, testValue.length);
expect(controller.selection.isCollapsed, isTrue);
});
testWidgets('Can long press to select', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
expect(controller.value.text, testValue);
await skipPastScrollingAnimation(tester);
expect(controller.selection.isCollapsed, true);
// Long press the 'e' to select 'def'.
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
await tester.longPressAt(ePos, pointer: 7);
await tester.pump();
// 'def' is selected.
expect(controller.selection.baseOffset, testValue.indexOf('d'));
expect(controller.selection.extentOffset, testValue.indexOf('f')+1);
// Tapping elsewhere immediately collapses and moves the cursor.
await tester.tapAt(textOffsetToPosition(tester, testValue.indexOf('h')));
await tester.pump();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('h'));
});
testWidgets("Slight movements in longpress don't hide/show handles", (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
expect(controller.value.text, testValue);
await skipPastScrollingAnimation(tester);
expect(controller.selection.isCollapsed, true);
// Long press the 'e' to select 'def', but don't release the gesture.
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
final TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
// Handles are shown
final Finder fadeFinder = find.byType(FadeTransition);
expect(fadeFinder, findsNWidgets(2)); // 2 handles, 1 toolbar
FadeTransition handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(1.0));
// Move the gesture very slightly
await gesture.moveBy(const Offset(1.0, 1.0));
await tester.pump(SelectionOverlay.fadeDuration * 0.5);
handle = tester.widget(fadeFinder.at(0));
// The handle should still be fully opaque.
expect(handle.opacity.value, equals(1.0));
});
testWidgets('Long pressing a field with selection 0,0 shows the selection menu', (WidgetTester tester) async {
await tester.pumpWidget(overlay(
child: TextField(
controller: TextEditingController.fromValue(
const TextEditingValue(
selection: TextSelection(baseOffset: 0, extentOffset: 0),
),
),
),
));
expect(find.text('Paste'), findsNothing);
final Offset emptyPos = textOffsetToPosition(tester, 0);
await tester.longPressAt(emptyPos, pointer: 7);
await tester.pumpAndSettle();
expect(find.text('Paste'), findsOneWidget);
}, skip: isContextMenuProvidedByPlatform); // [intended] only applies to platforms where we supply the context menu.
testWidgets('Entering text hides selection handle caret', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
),
),
);
const String testValue = 'abcdefghi';
await tester.enterText(find.byType(TextField), testValue);
expect(controller.value.text, testValue);
await skipPastScrollingAnimation(tester);
// Handle not shown.
expect(controller.selection.isCollapsed, true);
final Finder fadeFinder = find.byType(FadeTransition);
FadeTransition handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(0.0));
// Tap on the text field to show the handle.
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(fadeFinder, findsNWidgets(1));
handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(1.0));
// Enter more text.
const String testValueAddition = 'jklmni';
await tester.enterText(find.byType(TextField), testValueAddition);
expect(controller.value.text, testValueAddition);
await skipPastScrollingAnimation(tester);
// Handle not shown.
expect(controller.selection.isCollapsed, true);
handle = tester.widget(fadeFinder.at(0));
expect(handle.opacity.value, equals(0.0));
});
testWidgets('selection handles are excluded from the semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
),
),
);
const String testValue = 'abcdefghi';
await tester.enterText(find.byType(TextField), testValue);
expect(controller.value.text, testValue);
await skipPastScrollingAnimation(tester);
// Tap on the text field to show the handle.
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
// The semantics should only have the text field.
expect(semantics, hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
flags: <SemanticsFlag>[SemanticsFlag.isTextField, SemanticsFlag.isFocused],
actions: <SemanticsAction>[
SemanticsAction.tap,
SemanticsAction.moveCursorBackwardByCharacter,
SemanticsAction.setSelection,
SemanticsAction.paste,
SemanticsAction.setText,
SemanticsAction.moveCursorBackwardByWord,
],
value: 'abcdefghi',
textDirection: TextDirection.ltr,
textSelection: const TextSelection.collapsed(offset: 9),
),
],
),
ignoreRect: true,
ignoreTransform: true,
));
semantics.dispose();
});
testWidgets('Mouse long press is just like a tap', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
expect(controller.value.text, testValue);
await skipPastScrollingAnimation(tester);
expect(controller.selection.isCollapsed, true);
// Long press the 'e' using a mouse device.
final int eIndex = testValue.indexOf('e');
final Offset ePos = textOffsetToPosition(tester, eIndex);
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
// The cursor is placed just like a regular tap.
expect(controller.selection.baseOffset, eIndex);
expect(controller.selection.extentOffset, eIndex);
});
testWidgets('Read only text field basic', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'readonly');
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
readOnly: true,
),
),
);
// Read only text field cannot open keyboard.
await tester.showKeyboard(find.byType(TextField));
// On web, we always create a client connection to the engine.
expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse);
await skipPastScrollingAnimation(tester);
expect(controller.selection.isCollapsed, true);
await tester.tap(find.byType(TextField));
await tester.pump();
// On web, we always create a client connection to the engine.
expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse);
final EditableTextState editableText = tester.state(find.byType(EditableText));
// Collapse selection should not paint.
expect(editableText.selectionOverlay!.handlesAreVisible, isFalse);
// Long press on the 'd' character of text 'readOnly' to show context menu.
const int dIndex = 3;
final Offset dPos = textOffsetToPosition(tester, dIndex);
await tester.longPressAt(dPos);
await tester.pumpAndSettle();
// Context menu should not have paste and cut.
expect(find.text('Copy'), isContextMenuProvidedByPlatform ? findsNothing : findsOneWidget);
expect(find.text('Paste'), findsNothing);
expect(find.text('Cut'), findsNothing);
});
testWidgets('does not paint toolbar when no options available', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: TextField(
readOnly: true,
),
),
),
);
await tester.tap(find.byType(TextField));
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.byType(TextField));
// Wait for context menu to be built.
await tester.pumpAndSettle();
expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('text field build empty toolbar when no options available', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: TextField(
readOnly: true,
),
),
),
);
await tester.tap(find.byType(TextField));
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.byType(TextField));
// Wait for context menu to be built.
await tester.pumpAndSettle();
final RenderBox container = tester.renderObject(find.descendant(
of: find.byType(SnapshotWidget),
matching: find.byType(SizedBox),
).first);
expect(container.size, Size.zero);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows }));
testWidgets('Swapping controllers should update selection', (WidgetTester tester) async {
TextEditingController controller = TextEditingController(text: 'readonly');
final OverlayEntry entry = OverlayEntry(
builder: (BuildContext context) {
return Center(
child: Material(
child: TextField(
controller: controller,
readOnly: true,
),
),
);
},
);
await tester.pumpWidget(overlayWithEntry(entry));
const int dIndex = 3;
final Offset dPos = textOffsetToPosition(tester, dIndex);
await tester.longPressAt(dPos);
await tester.pumpAndSettle();
final EditableTextState state = tester.state(find.byType(EditableText));
TextSelection currentOverlaySelection =
state.selectionOverlay!.value.selection;
expect(currentOverlaySelection.baseOffset, 0);
expect(currentOverlaySelection.extentOffset, 8);
// Update selection from [0 to 8] to [1 to 7].
controller = TextEditingController.fromValue(
controller.value.copyWith(selection: const TextSelection(
baseOffset: 1,
extentOffset: 7,
)),
);
// Mark entry to be dirty in order to trigger overlay update.
entry.markNeedsBuild();
await tester.pump();
currentOverlaySelection = state.selectionOverlay!.value.selection;
expect(currentOverlaySelection.baseOffset, 1);
expect(currentOverlaySelection.extentOffset, 7);
});
testWidgets('Read only text should not compose', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController.fromValue(
const TextEditingValue(
text: 'readonly',
composing: TextRange(start: 0, end: 8), // Simulate text composing.
),
);
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
readOnly: true,
),
),
);
final RenderEditable renderEditable = findRenderEditable(tester);
// There should be no composing.
expect(renderEditable.text, TextSpan(text:'readonly', style: renderEditable.text!.style));
});
testWidgets('Dynamically switching between read only and not read only should hide or show collapse cursor', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'readonly');
bool readOnly = true;
final OverlayEntry entry = OverlayEntry(
builder: (BuildContext context) {
return Center(
child: Material(
child: TextField(
controller: controller,
readOnly: readOnly,
),
),
);
},
);
await tester.pumpWidget(overlayWithEntry(entry));
await tester.tap(find.byType(TextField));
await tester.pump();
final EditableTextState editableText = tester.state(find.byType(EditableText));
// Collapse selection should not paint.
expect(editableText.selectionOverlay!.handlesAreVisible, isFalse);
readOnly = false;
// Mark entry to be dirty in order to trigger overlay update.
entry.markNeedsBuild();
await tester.pumpAndSettle();
expect(editableText.selectionOverlay!.handlesAreVisible, isTrue);
readOnly = true;
entry.markNeedsBuild();
await tester.pumpAndSettle();
expect(editableText.selectionOverlay!.handlesAreVisible, isFalse);
});
testWidgets('Dynamically switching to read only should close input connection', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'readonly');
bool readOnly = false;
final OverlayEntry entry = OverlayEntry(
builder: (BuildContext context) {
return Center(
child: Material(
child: TextField(
controller: controller,
readOnly: readOnly,
),
),
);
},
);
await tester.pumpWidget(overlayWithEntry(entry));
await tester.tap(find.byType(TextField));
await tester.pump();
expect(tester.testTextInput.hasAnyClients, true);
readOnly = true;
// Mark entry to be dirty in order to trigger overlay update.
entry.markNeedsBuild();
await tester.pump();
// On web, we always have a client connection to the engine.
expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse);
});
testWidgets('Dynamically switching to non read only should open input connection', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'readonly');
bool readOnly = true;
final OverlayEntry entry = OverlayEntry(
builder: (BuildContext context) {
return Center(
child: Material(
child: TextField(
controller: controller,
readOnly: readOnly,
),
),
);
},
);
await tester.pumpWidget(overlayWithEntry(entry));
await tester.tap(find.byType(TextField));
await tester.pump();
// On web, we always have a client connection to the engine.
expect(tester.testTextInput.hasAnyClients, isBrowser ? isTrue : isFalse);
readOnly = false;
// Mark entry to be dirty in order to trigger overlay update.
entry.markNeedsBuild();
await tester.pump();
expect(tester.testTextInput.hasAnyClients, true);
});
testWidgets('enableInteractiveSelection = false, long-press', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
controller: controller,
enableInteractiveSelection: false,
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
expect(controller.value.text, testValue);
await skipPastScrollingAnimation(tester);
expect(controller.selection.isCollapsed, true);
// Long press the 'e' to select 'def'.
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
await tester.longPressAt(ePos, pointer: 7);
await tester.pump();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.length);
});
testWidgets('Selection updates on tap down (Desktop platforms)', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, 5);
final Offset gPos = textOffsetToPosition(tester, 8);
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 5);
expect(controller.selection.extentOffset, 5);
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
await gesture.down(gPos);
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 8);
expect(controller.selection.extentOffset, 8);
// This should do nothing. The selection is set on tap down on desktop platforms.
await gesture.up();
expect(controller.selection.baseOffset, 8);
expect(controller.selection.extentOffset, 8);
},
variant: TargetPlatformVariant.desktop(),
);
testWidgets('Selection updates on tap up (Mobile platforms)', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
final bool isTargetPlatformApple = defaultTargetPlatform == TargetPlatform.iOS;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: controller),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, 5);
final Offset gPos = textOffsetToPosition(tester, 8);
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
await gesture.down(gPos);
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 5);
expect(controller.selection.extentOffset, 5);
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.baseOffset, 8);
expect(controller.selection.extentOffset, 8);
final TestGesture touchGesture = await tester.startGesture(ePos);
await touchGesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
// On iOS a tap to select, selects the word edge instead of the exact tap position.
expect(controller.selection.baseOffset, isTargetPlatformApple ? 7 : 5);
expect(controller.selection.extentOffset, isTargetPlatformApple ? 7 : 5);
// Selection should stay the same since it is set on tap up for mobile platforms.
await touchGesture.down(gPos);
await tester.pump();
expect(controller.selection.baseOffset, isTargetPlatformApple ? 7 : 5);
expect(controller.selection.extentOffset, isTargetPlatformApple ? 7 : 5);
await touchGesture.up();
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, 8);
expect(controller.selection.extentOffset, 8);
},
variant: TargetPlatformVariant.mobile(),
);
testWidgets('Can select text with a mouse when wrapped in a GestureDetector with tap/double tap callbacks', (WidgetTester tester) async {
// This is a regression test for https://github.com/flutter/flutter/issues/129161.
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: GestureDetector(
onTap: () {},
onDoubleTap: () {},
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
await tester.pump();
await gesture.up();
// This is to allow the GestureArena to decide a winner between TapGestureRecognizer,
// DoubleTapGestureRecognizer, and BaseTapAndDragGestureRecognizer.
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('e'));
await gesture.down(ePos);
await tester.pump();
await gesture.moveTo(gPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, testValue.indexOf('e'));
expect(controller.selection.extentOffset, testValue.indexOf('g'));
}, variant: TargetPlatformVariant.desktop());
testWidgets('Can select text by dragging with a mouse', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
await tester.pump();
await gesture.moveTo(gPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, testValue.indexOf('e'));
expect(controller.selection.extentOffset, testValue.indexOf('g'));
});
testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS)', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i'));
// Tap on text field to gain focus, and set selection to '|g'. On iOS
// the selection is set to the word edge closest to the tap position.
// We await for kDoubleTapTimeout after the up event, so our next down event
// does not register as a double tap.
final TestGesture gesture = await tester.startGesture(ePos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, 7);
// If the position we tap during a drag start is on the collapsed selection, then
// we can move the cursor with a drag.
// Here we tap on '|g', where our selection was previously, and move to '|i'.
await gesture.down(textOffsetToPosition(tester, 7));
await tester.pump();
await gesture.moveTo(iPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('i'));
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets('Cursor should not move on a quick touch drag when touch does not begin on previous selection (iOS)', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a'));
final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i'));
// Tap on text field to gain focus, and set selection to '|a'. On iOS
// the selection is set to the word edge closest to the tap position.
// We await for kDoubleTapTimeout after the up event, so our next down event
// does not register as a double tap.
final TestGesture gesture = await tester.startGesture(aPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, 0);
// The position we tap during a drag start is not on the collapsed selection,
// so the cursor should not move.
await gesture.down(textOffsetToPosition(tester, 7));
await gesture.moveTo(iPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, 0);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS) - multiline', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
maxLines: null,
),
),
),
);
const String testValue = 'abc\ndef\nghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a'));
final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i'));
// Tap on text field to gain focus, and set selection to '|a'. On iOS
// the selection is set to the word edge closest to the tap position.
// We await for kDoubleTapTimeout after the up event, so our next down event
// does not register as a double tap.
final TestGesture gesture = await tester.startGesture(aPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, 0);
// If the position we tap during a drag start is on the collapsed selection, then
// we can move the cursor with a drag.
// Here we tap on '|a', where our selection was previously, and move to '|i'.
await gesture.down(aPos);
await tester.pump();
await gesture.moveTo(iPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('i'));
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets('Can move cursor when dragging, when tap is on collapsed selection (iOS) - ListView', (WidgetTester tester) async {
// This is a regression test for
// https://github.com/flutter/flutter/issues/122519
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ListView(
children: <Widget>[
TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
maxLines: null,
),
],
),
),
),
);
const String testValue = 'abc\ndef\nghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
final Offset iPos = textOffsetToPosition(tester, testValue.indexOf('i'));
// Tap on text field to gain focus, and set selection to '|a'. On iOS
// the selection is set to the word edge closest to the tap position.
// We await for kDoubleTapTimeout after the up event, so our next down event
// does not register as a double tap.
final TestGesture gesture = await tester.startGesture(aPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, 0);
// If the position we tap during a drag start is on the collapsed selection, then
// we can move the cursor with a drag.
// Here we tap on '|a', where our selection was previously, and attempt move
// to '|g'. The cursor will not move because the `VerticalDragGestureRecognizer`
// in the scrollable will beat the `TapAndHorizontalDragGestureRecognizer`
// in the TextField. This is because moving from `|a` to `|g` is a completely
// vertical movement.
await gesture.down(aPos);
await tester.pump();
await gesture.moveTo(gPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, 0);
// Release the pointer.
await gesture.up();
await tester.pumpAndSettle();
// If the position we tap during a drag start is on the collapsed selection, then
// we can move the cursor with a drag.
// Here we tap on '|a', where our selection was previously, and move to '|i'.
// Unlike our previous attempt to drag to `|g`, this works because moving
// to `|i` includes a horizontal movement so the `TapAndHorizontalDragGestureRecognizer`
// in TextField can beat the `VerticalDragGestureRecognizer` in the scrollable.
await gesture.down(aPos);
await tester.pump();
await gesture.moveTo(iPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('i'));
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }),
);
testWidgets('Can move cursor when dragging (Android)', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
// Tap on text field to gain focus, and set selection to '|e'.
// We await for kDoubleTapTimeout after the up event, so our next down event
// does not register as a double tap.
final TestGesture gesture = await tester.startGesture(ePos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('e'));
// Here we tap on '|d', and move to '|g'.
await gesture.down(textOffsetToPosition(tester, testValue.indexOf('d')));
await tester.pump();
await gesture.moveTo(gPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('g'));
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia }),
);
testWidgets('Can move cursor when dragging (Android) - multiline', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
maxLines: null,
),
),
),
);
const String testValue = 'abc\ndef\nghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
// Tap on text field to gain focus, and set selection to '|a'.
// We await for kDoubleTapTimeout after the up event, so our next down event
// does not register as a double tap.
final TestGesture gesture = await tester.startGesture(aPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('a'));
// Here we tap on '|c', and move down to '|g'.
await gesture.down(textOffsetToPosition(tester, testValue.indexOf('c')));
await tester.pump();
await gesture.moveTo(gPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('g'));
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia }),
);
testWidgets('Can move cursor when dragging (Android) - ListView', (WidgetTester tester) async {
// This is a regression test for
// https://github.com/flutter/flutter/issues/122519
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ListView(
children: <Widget>[
TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
maxLines: null,
),
],
),
),
),
);
const String testValue = 'abc\ndef\nghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset aPos = textOffsetToPosition(tester, testValue.indexOf('a'));
final Offset cPos = textOffsetToPosition(tester, testValue.indexOf('c'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
// Tap on text field to gain focus, and set selection to '|c'.
// We await for kDoubleTapTimeout after the up event, so our next down event
// does not register as a double tap.
final TestGesture gesture = await tester.startGesture(cPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('c'));
// Here we tap on '|a', and attempt move to '|g'. The cursor will not move
// because the `VerticalDragGestureRecognizer` in the scrollable will beat
// the `TapAndHorizontalDragGestureRecognizer` in the TextField. This is
// because moving from `|a` to `|g` is a completely vertical movement.
await gesture.down(aPos);
await tester.pump();
await gesture.moveTo(gPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('c'));
// Release the pointer.
await gesture.up();
await tester.pumpAndSettle();
// Here we tap on '|c', and move to '|g'. Unlike our previous attempt to
// drag to `|g`, this works because moving from `|c` to `|g` includes a
// horizontal movement so the `TapAndHorizontalDragGestureRecognizer`
// in TextField can beat the `VerticalDragGestureRecognizer` in the scrollable.
await gesture.down(cPos);
await tester.pump();
await gesture.moveTo(gPos);
await tester.pumpAndSettle();
expect(controller.selection.isCollapsed, true);
expect(controller.selection.baseOffset, testValue.indexOf('g'));
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia }),
);
testWidgets('Continuous dragging does not cause flickering', (WidgetTester tester) async {
int selectionChangedCount = 0;
const String testValue = 'abc def ghi';
final TextEditingController controller = TextEditingController(text: testValue);
controller.addListener(() {
selectionChangedCount++;
});
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
style: const TextStyle(fontSize: 10.0),
),
),
),
);
final Offset cPos = textOffsetToPosition(tester, 2); // Index of 'c'.
final Offset gPos = textOffsetToPosition(tester, 8); // Index of 'g'.
final Offset hPos = textOffsetToPosition(tester, 9); // Index of 'h'.
// Drag from 'c' to 'g'.
final TestGesture gesture = await tester.startGesture(cPos, kind: PointerDeviceKind.mouse);
await tester.pump();
await gesture.moveTo(gPos);
await tester.pumpAndSettle();
expect(selectionChangedCount, isNonZero);
selectionChangedCount = 0;
expect(controller.selection.baseOffset, 2);
expect(controller.selection.extentOffset, 8);
// Tiny movement shouldn't cause text selection to change.
await gesture.moveTo(gPos + const Offset(2.0, 0.0));
await tester.pumpAndSettle();
expect(selectionChangedCount, 0);
// Now a text selection change will occur after a significant movement.
await gesture.moveTo(hPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(selectionChangedCount, 1);
expect(controller.selection.baseOffset, 2);
expect(controller.selection.extentOffset, 9);
});
testWidgets('Dragging in opposite direction also works', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
final TestGesture gesture = await tester.startGesture(gPos, kind: PointerDeviceKind.mouse);
await tester.pump();
await gesture.moveTo(ePos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(controller.selection.baseOffset, testValue.indexOf('g'));
expect(controller.selection.extentOffset, testValue.indexOf('e'));
});
testWidgets('Slow mouse dragging also selects text', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
final Offset gPos = textOffsetToPosition(tester, testValue.indexOf('g'));
final TestGesture gesture = await tester.startGesture(ePos, kind: PointerDeviceKind.mouse);
await tester.pump(const Duration(seconds: 2));
await gesture.moveTo(gPos);
await tester.pump();
await gesture.up();
expect(controller.selection.baseOffset, testValue.indexOf('e'));
expect(controller.selection.extentOffset, testValue.indexOf('g'));
});
testWidgets('Can drag handles to change selection on Apple platforms', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
// Double tap the 'e' to select 'def'.
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
// The first tap.
TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
await tester.pump();
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
// The second tap.
await gesture.down(ePos);
await tester.pump();
await gesture.up();
await tester.pump();
final TextSelection selection = controller.selection;
expect(selection.baseOffset, 4);
expect(selection.extentOffset, 7);
final RenderEditable renderEditable = findRenderEditable(tester);
List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
expect(endpoints.length, 2);
// Drag the right handle 2 letters to the right.
// We use a small offset because the endpoint is on the very corner
// of the handle.
Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
Offset newHandlePos = textOffsetToPosition(tester, testValue.length);
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 11);
// Drag the left handle 2 letters to the left.
handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
newHandlePos = textOffsetToPosition(tester, 2);
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
switch (defaultTargetPlatform) {
// On Apple platforms, dragging the base handle makes it the extent.
case TargetPlatform.iOS:
case TargetPlatform.macOS:
expect(controller.selection.baseOffset, 11);
expect(controller.selection.extentOffset, 2);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(controller.selection.baseOffset, 2);
expect(controller.selection.extentOffset, 11);
}
// Drag the left handle 2 letters to the left again.
endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
renderEditable,
);
handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
newHandlePos = textOffsetToPosition(tester, 0);
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// The left handle was already the extent, and it remains so.
expect(controller.selection.baseOffset, 11);
expect(controller.selection.extentOffset, 0);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 11);
}
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets('Can drag handles to change selection on non-Apple platforms', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController();
await tester.pumpWidget(
overlay(
child: TextField(
dragStartBehavior: DragStartBehavior.down,
controller: controller,
),
),
);
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(TextField), testValue);
await skipPastScrollingAnimation(tester);
// Long press the 'e' to select 'def'.
final Offset ePos = textOffsetToPosition(tester, testValue.indexOf('e'));
TestGesture gesture = await tester.startGesture(ePos, pointer: 7);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // skip past the frame where the opacity is zero
final TextSelection selection = controller.selection;
expect(selection.baseOffset, 4);
expect(selection.extentOffset, 7);
final RenderEditable renderEditable = findRenderEditable(tester);
List<TextSelectionPoint> endpoints = globalize(
renderEditable.getEndpointsForSelection(selection),
renderEditable,
);
expect(endpoints.length, 2);
// Drag the right handle 2 letters to the right.
// We use a small offset because the endpoint is on the very corner
// of the handle.
Offset handlePos = endpoints[1].point + const Offset(1.0, 1.0);
Offset newHandlePos = textOffsetToPosition(tester, testValue.length);
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
expect(controller.selection.baseOffset, 4);
expect(controller.selection.extentOffset, 11);
// Drag the left handle 2 letters to the left.
handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
newHandlePos = textOffsetToPosition(tester, 2);
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up();
await tester.pump();
switch (defaultTargetPlatform) {
// On Apple platforms, dragging the base handle makes it the extent.
case TargetPlatform.iOS:
case TargetPlatform.macOS:
expect(controller.selection.baseOffset, 11);
expect(controller.selection.extentOffset, 2);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
expect(controller.selection.baseOffset, 2);
expect(controller.selection.extentOffset, 11);
}
// Drag the left handle 2 letters to the left again.
endpoints = globalize(
renderEditable.getEndpointsForSelection(controller.selection),
renderEditable,
);
handlePos = endpoints[0].point + const Offset(-1.0, 1.0);
newHandlePos = textOffsetToPosition(tester, 0);
gesture = await tester.startGesture(handlePos, pointer: 7);
await tester.pump();
await gesture.moveTo(newHandlePos);
await tester.pump();
await gesture.up