Match iOS Longpress behavior with native (#123630)

Match iOS Longpress behavior with native
This commit is contained in:
Renzo Olivares 2023-03-29 13:22:03 -07:00 committed by GitHub
parent 9f2ac97174
commit 142dd6022f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 331 additions and 19 deletions

View file

@ -2005,6 +2005,14 @@ class TextSelectionGestureDetectorBuilder {
// cursor will not move on drag update.
bool? _dragBeganOnPreviousSelection;
// For iOS long press behavior when the field is not focused. iOS uses this value
// to determine if a long press began on a field that was not focused.
//
// If the field was not focused when the long press began, a long press will select
// the word and a long press move will select word-by-word. If the field was
// focused, the cursor moves to the long press position.
bool _longPressStartedWithoutFocus = false;
/// Handler for [TextSelectionGestureDetector.onTapDown].
///
/// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets
@ -2240,10 +2248,15 @@ class TextSelectionGestureDetectorBuilder {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
if (!renderEditable.hasFocus) {
_longPressStartedWithoutFocus = true;
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
} else {
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
}
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
@ -2291,10 +2304,18 @@ class TextSelectionGestureDetectorBuilder {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
if (_longPressStartedWithoutFocus) {
renderEditable.selectWordsInRange(
from: details.globalPosition - details.offsetFromOrigin - editableOffset - scrollableOffset,
to: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
} else {
renderEditable.selectPositionAt(
from: details.globalPosition,
cause: SelectionChangedCause.longPress,
);
}
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
@ -2342,6 +2363,7 @@ class TextSelectionGestureDetectorBuilder {
if (shouldShowSelectionToolbar) {
editableText.showToolbar();
}
_longPressStartedWithoutFocus = false;
_dragStartViewportOffset = 0.0;
_dragStartScrollOffset = 0.0;
}

View file

@ -1591,6 +1591,7 @@ void main() {
home: Column(
children: <Widget>[
CupertinoTextField(
autofocus: true,
controller: controller,
toolbarOptions: const ToolbarOptions(copy: true),
),
@ -1599,6 +1600,9 @@ void main() {
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
// Long press to put the cursor after the "w".
const int index = 3;
await tester.longPressAt(textOffsetToPosition(tester, index));
@ -2060,12 +2064,16 @@ void main() {
CupertinoApp(
home: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
// Long press to put the cursor after the "w".
const int index = 3;
await tester.longPressAt(textOffsetToPosition(tester, index));
@ -2830,12 +2838,16 @@ void main() {
CupertinoApp(
home: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
await tester.longPressAt(textFieldStart + const Offset(50.0, 5.0));
@ -2870,12 +2882,16 @@ void main() {
CupertinoApp(
home: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r'
await tester.longPressAt(ePos);
@ -2971,7 +2987,7 @@ void main() {
);
testWidgets(
'long press drag moves the cursor under the drag and shows toolbar on lift on Apple platforms',
'long press drag on a focused TextField moves the cursor under the drag and shows toolbar on lift on Apple platforms',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
@ -2980,12 +2996,16 @@ void main() {
CupertinoApp(
home: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
final Offset textFieldStart = tester.getTopLeft(find.byType(CupertinoTextField));
final TestGesture gesture =
@ -3135,12 +3155,16 @@ void main() {
CupertinoApp(
home: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
final RenderEditable renderEditable = tester.renderObject<RenderEditable>(
find.byElementPredicate((Element element) => element.renderObject is RenderEditable).last,
);
@ -3289,12 +3313,16 @@ void main() {
CupertinoApp(
home: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
// Use a position higher than wPos to avoid tapping the context menu on
// desktop.
final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'
@ -7313,6 +7341,7 @@ void main() {
child: Column(
children: <Widget>[
CupertinoTextField(
autofocus: true,
key: const Key('field0'),
controller: controller,
style: const TextStyle(height: 4, color: ui.Color.fromARGB(100, 0, 0, 0)),
@ -7329,6 +7358,9 @@ void main() {
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0')));
await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0));
@ -7363,6 +7395,7 @@ void main() {
child: Column(
children: <Widget>[
CupertinoTextField(
autofocus: true,
key: const Key('field0'),
controller: controller,
style: const TextStyle(height: 4, color: ui.Color.fromARGB(100, 0, 0, 0)),
@ -7378,6 +7411,9 @@ void main() {
),
);
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
final Offset textFieldStart = tester.getTopLeft(find.byKey(const Key('field0')));
await tester.longPressAt(textFieldStart + const Offset(50.0, 2.0));

View file

@ -200,6 +200,7 @@ void main() {
data: const MediaQueryData(size: Size(800.0, 600.0)),
child: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
@ -207,6 +208,9 @@ void main() {
),
));
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
// Initially, the menu isn't shown at all.
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);
@ -432,6 +436,7 @@ void main() {
data: const MediaQueryData(size: Size(800.0, 600.0)),
child: Center(
child: CupertinoTextField(
autofocus: true,
controller: controller,
),
),
@ -439,6 +444,9 @@ void main() {
),
));
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
// Initially, the menu isn't shown at all.
expect(find.text(_longLocalizations.cutButtonLabel), findsNothing);
expect(find.text(_longLocalizations.copyButtonLabel), findsNothing);
@ -546,6 +554,7 @@ void main() {
data: const MediaQueryData(size: Size(800.0, 600.0)),
child: Center(
child: CupertinoTextField(
autofocus: true,
padding: const EdgeInsets.all(8.0),
controller: controller,
maxLines: 2,
@ -555,6 +564,9 @@ void main() {
),
));
// This extra pump is so autofocus can propagate to renderEditable.
await tester.pump();
// Initially, the menu isn't shown at all.
expect(find.text('Cut'), findsNothing);
expect(find.text('Copy'), findsNothing);

View file

@ -10717,7 +10717,51 @@ void main() {
);
testWidgets(
'long press moves cursor to the exact long press position and shows toolbar',
'long press moves cursor to the exact long press position and shows toolbar when the field is focused',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
autofocus: true,
controller: controller,
),
),
),
),
);
// This extra pump allows the selection set by autofocus to propagate to
// the RenderEditable.
await tester.pump();
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0));
await tester.pumpAndSettle();
// Collapsed cursor for iOS long press.
expect(
controller.selection,
const TextSelection.collapsed(offset: 3),
);
// Collapsed toolbar shows 2 buttons.
final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
'long press that starts on an unfocused TextField selects the word at the exact long press position and shows toolbar',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
@ -10742,14 +10786,13 @@ void main() {
// Collapsed cursor for iOS long press.
expect(
controller.selection,
const TextSelection.collapsed(offset: 3),
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Collapsed toolbar shows 2 buttons.
final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
// Collapsed toolbar shows 3 buttons.
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
@ -10800,6 +10843,7 @@ void main() {
home: Material(
child: Center(
child: TextField(
autofocus: true,
controller: controller,
),
),
@ -10807,6 +10851,9 @@ void main() {
),
);
// This extra pump is so autofocus can propogate to renderEditable.
await tester.pump();
final Offset ePos = textOffsetToPosition(tester, 6); // Index of 'Atwate|r'
await tester.longPressAt(ePos);
@ -10910,7 +10957,7 @@ void main() {
);
testWidgets(
'long press drag moves the cursor under the drag and shows toolbar on lift',
'long press drag on a focused TextField moves the cursor under the drag and shows toolbar on lift',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
@ -10920,6 +10967,7 @@ void main() {
home: Material(
child: Center(
child: TextField(
autofocus: true,
controller: controller,
),
),
@ -10927,6 +10975,9 @@ void main() {
),
);
// This extra pump is so autofocus can propogate to renderEditable.
await tester.pump();
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
final TestGesture gesture =
@ -10981,6 +11032,77 @@ void main() {
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets(
'long press drag on an unfocused TextField selects word-by-word and shows toolbar on lift',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
final TestGesture gesture =
await tester.startGesture(textfieldStart + const Offset(50.0, 9.0));
await tester.pump(const Duration(milliseconds: 500));
// Long press on iOS shows collapsed selection cursor.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
// Cursor move doesn't trigger a toolbar initially.
expect(find.byType(CupertinoButton), findsNothing);
await gesture.moveBy(const Offset(100, 0));
await tester.pump();
// The selection position is now moved with the drag.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 12),
);
// Still no toolbar.
expect(find.byType(CupertinoButton), findsNothing);
await gesture.moveBy(const Offset(100, 0));
await tester.pump();
// The selection position is now moved with the drag.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 23),
);
// Still no toolbar.
expect(find.byType(CupertinoButton), findsNothing);
await gesture.up();
await tester.pumpAndSettle();
// The selection isn't affected by the gesture lift.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 23),
);
// The toolbar now shows up.
expect(
find.byType(CupertinoButton),
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3),
);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
testWidgets('long press drag can edge scroll on non-Apple platforms', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
@ -11069,7 +11191,7 @@ void main() {
expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows }));
testWidgets('long press drag can edge scroll on Apple platforms', (WidgetTester tester) async {
testWidgets('long press drag can edge scroll on Apple platforms - unfocused TextField', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
);
@ -11098,6 +11220,98 @@ void main() {
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
final TestGesture gesture =
await tester.startGesture(textfieldStart);
await tester.pump(const Duration(milliseconds: 500));
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream),
);
expect(find.byType(CupertinoButton), findsNothing);
await gesture.moveBy(const Offset(900, 5));
// To the edge of the screen basically.
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 59),
);
// Keep moving out.
await gesture.moveBy(const Offset(1, 0));
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 66),
);
await gesture.moveBy(const Offset(1, 0));
await tester.pump();
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream),
); // We're at the edge now.
expect(find.byType(CupertinoButton), findsNothing);
await gesture.up();
await tester.pumpAndSettle();
// The selection isn't affected by the gesture lift.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 66, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
lastCharEndpoint = renderEditable.getEndpointsForSelection(
const TextSelection.collapsed(offset: 66), // Last character's position.
);
expect(lastCharEndpoint.length, 1);
// The last character is now on screen near the right edge.
expect(lastCharEndpoint[0].point.dx, moreOrLessEquals(798, epsilon: 1));
final List<TextSelectionPoint> firstCharEndpoint = renderEditable.getEndpointsForSelection(
const TextSelection.collapsed(offset: 0), // First character's position.
);
expect(firstCharEndpoint.length, 1);
// The first character is now offscreen to the left.
expect(firstCharEndpoint[0].point.dx, moreOrLessEquals(-257.0, epsilon: 1));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('long press drag can edge scroll on Apple platforms - focused TextField', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure Angrignon Peel Côte-des-Neiges',
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
autofocus: true,
controller: controller,
),
),
),
),
);
// This extra pump is so autofocus can propogate to renderEditable.
await tester.pump();
final RenderEditable renderEditable = findRenderEditable(tester);
List<TextSelectionPoint> lastCharEndpoint = renderEditable.getEndpointsForSelection(
const TextSelection.collapsed(offset: 66), // Last character's position.
);
expect(lastCharEndpoint.length, 1);
// Just testing the test and making sure that the last character is off
// the right side of the screen.
expect(lastCharEndpoint[0].point.dx, 1056);
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
final TestGesture gesture =
await tester.startGesture(textfieldStart + const Offset(300, 5));
await tester.pump(const Duration(milliseconds: 500));
@ -11309,6 +11523,7 @@ void main() {
home: Material(
child: Center(
child: TextField(
autofocus: true,
maxLines: 2,
controller: controller,
),
@ -11317,6 +11532,9 @@ void main() {
),
);
// This extra pump is so autofocus can propogate to renderEditable.
await tester.pump();
// Just testing the test and making sure that the last character is outside
// the bottom of the field.
final int textLength = controller.text.length;
@ -11595,6 +11813,7 @@ void main() {
home: Material(
child: Center(
child: TextField(
autofocus: true,
controller: controller,
),
),
@ -11602,6 +11821,9 @@ void main() {
),
);
// This extra pump is so autofocus can propogate to renderEditable.
await tester.pump();
// The second tap is slightly higher to avoid tapping the context menu on
// desktop.
final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'

View file

@ -460,8 +460,12 @@ void main() {
expect(dragEndCount, 1);
});
testWidgets('test TextSelectionGestureDetectorBuilder long press on Apple Platforms', (WidgetTester tester) async {
testWidgets('test TextSelectionGestureDetectorBuilder long press on Apple Platforms - focused renderEditable', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
renderEditable.hasFocus = true;
final TestGesture gesture = await tester.startGesture(
const Offset(200.0, 200.0),
pointer: 0,
@ -470,13 +474,29 @@ void main() {
await gesture.up();
await tester.pumpAndSettle();
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
expect(state.showToolbarCalled, isTrue);
expect(renderEditable.selectPositionAtCalled, isTrue);
expect(renderEditable.lastCause, SelectionChangedCause.longPress);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('test TextSelectionGestureDetectorBuilder long press on iOS - renderEditable not focused', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
final TestGesture gesture = await tester.startGesture(
const Offset(200.0, 200.0),
pointer: 0,
);
await tester.pump(const Duration(seconds: 2));
await gesture.up();
await tester.pumpAndSettle();
expect(state.showToolbarCalled, isTrue);
expect(renderEditable.selectWordCalled, isTrue);
expect(renderEditable.lastCause, SelectionChangedCause.longPress);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
testWidgets('test TextSelectionGestureDetectorBuilder long press on non-Apple Platforms', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final TestGesture gesture = await tester.startGesture(