iOS selection handles are invisible (#31332) (#31862)
Fix a bug where text selection handles were invisible in iOS
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index d929ca2..c558b58 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -654,4 +654,4 @@
final List<int> indices = _paragraph.getWordBoundary(position.offset);
return TextRange(start: indices[0], end: indices[1]);
}
-}
\ No newline at end of file
+}
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index fe0bb7e..68c6921 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -302,15 +302,25 @@
TextPosition(offset: _selection.start, affinity: _selection.affinity),
Rect.zero,
);
-
- _selectionStartInViewport.value = visibleRegion.contains(startOffset + effectiveOffset);
+ // TODO(justinmc): https://github.com/flutter/flutter/issues/31495
+ // Check if the selection is visible with an approximation because a
+ // difference between rounded and unrounded values causes the caret to be
+ // reported as having a slightly (< 0.5) negative y offset. This rounding
+ // happens in paragraph.cc's layout and TextPainer's
+ // _applyFloatingPointHack. Ideally, the rounding mismatch will be fixed and
+ // this can be changed to be a strict check instead of an approximation.
+ const double visibleRegionSlop = 0.5;
+ _selectionStartInViewport.value = visibleRegion
+ .inflate(visibleRegionSlop)
+ .contains(startOffset + effectiveOffset);
final Offset endOffset = _textPainter.getOffsetForCaret(
TextPosition(offset: _selection.end, affinity: _selection.affinity),
Rect.zero,
);
-
- _selectionEndInViewport.value = visibleRegion.contains(endOffset + effectiveOffset);
+ _selectionEndInViewport.value = visibleRegion
+ .inflate(visibleRegionSlop)
+ .contains(endOffset + effectiveOffset);
}
static const int _kLeftArrowCode = 21;
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index 2427b0c..28096e0 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -612,7 +612,6 @@
point.dy.clamp(0.0, viewport.height),
);
-
return CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: false,
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index 5f427db..5c8a124 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -2091,4 +2091,39 @@
final EditableText editableText = tester.firstWidget(find.byType(EditableText));
expect(editableText.cursorColor, const Color(0xFFF44336));
});
+
+ testWidgets('iOS shows selection handles', (WidgetTester tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ const String testText = 'lorem ipsum';
+ final TextEditingController controller = TextEditingController(text: testText);
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ theme: const CupertinoThemeData(),
+ home: Center(
+ child: CupertinoTextField(
+ controller: controller,
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable renderEditable =
+ tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
+
+ await tester.tapAt(textOffsetToPosition(tester, 5));
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pumpAndSettle();
+
+ final List<Widget> transitions =
+ find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
+ expect(transitions.length, 2);
+ final FadeTransition left = transitions[0];
+ final FadeTransition right = transitions[1];
+
+ expect(left.opacity.value, equals(1.0));
+ expect(right.opacity.value, equals(1.0));
+
+ debugDefaultTargetPlatformOverride = null;
+ });
}
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 7366e69..48f5c01 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -5676,4 +5676,71 @@
);
expect(topLeft.dx, equals(383)); // Should be same as equivalent in 'Caret center position'
});
+
+ testWidgets('selection handles are rendered and not faded away', (WidgetTester tester) async {
+ const String testText = 'lorem ipsum';
+ final TextEditingController controller = TextEditingController(text: testText);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: TextField(
+ controller: controller,
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable renderEditable =
+ tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
+
+ await tester.tapAt(const Offset(20, 10));
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pumpAndSettle();
+
+ final List<Widget> transitions =
+ find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
+ // On Android, an empty app contains a single FadeTransition. The following
+ // two are the left and right text selection handles, respectively.
+ expect(transitions.length, 3);
+ final FadeTransition left = transitions[1];
+ final FadeTransition right = transitions[2];
+
+ expect(left.opacity.value, equals(1.0));
+ expect(right.opacity.value, equals(1.0));
+ });
+
+ testWidgets('iOS selection handles are rendered and not faded away', (WidgetTester tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ const String testText = 'lorem ipsum';
+ final TextEditingController controller = TextEditingController(text: testText);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: TextField(
+ controller: controller,
+ ),
+ ),
+ ),
+ );
+
+ final RenderEditable renderEditable =
+ tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
+
+ await tester.tapAt(const Offset(20, 10));
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pumpAndSettle();
+
+ final List<Widget> transitions =
+ find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
+ expect(transitions.length, 2);
+ final FadeTransition left = transitions[0];
+ final FadeTransition right = transitions[1];
+
+ expect(left.opacity.value, equals(1.0));
+ expect(right.opacity.value, equals(1.0));
+
+ debugDefaultTargetPlatformOverride = null;
+ });
}
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index f12f114..e7f1ddb 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -7,6 +7,7 @@
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/cupertino.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
import 'package:mockito/mockito.dart';
@@ -21,6 +22,10 @@
const TextStyle textStyle = TextStyle();
const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
+enum HandlePositionInViewport {
+ leftEdge, rightEdge, within,
+}
+
void main() {
setUp(() {
debugResetSemanticsIdCounter();
@@ -469,14 +474,10 @@
});
testWidgets('Fires onChanged when text changes via TextSelectionOverlay', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
-
String changedValue;
final Widget widget = MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: TextEditingController(),
focusNode: FocusNode(),
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -500,7 +501,7 @@
});
// Long-press to bring up the text editing controls.
- final Finder textFinder = find.byKey(editableTextKey);
+ final Finder textFinder = find.byType(EditableText);
await tester.longPress(textFinder);
tester.state<EditableTextState>(textFinder).showToolbar();
await tester.pump();
@@ -512,14 +513,11 @@
});
testWidgets('Does not lose focus by default when "next" action is pressed', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
final Widget widget = MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -531,7 +529,7 @@
await tester.pumpWidget(widget);
// Select EditableText to give it focus.
- final Finder textFinder = find.byKey(editableTextKey);
+ final Finder textFinder = find.byType(EditableText);
await tester.tap(textFinder);
await tester.pump();
@@ -545,14 +543,11 @@
});
testWidgets('Does not lose focus by default when "done" action is pressed and onEditingComplete is provided', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
final Widget widget = MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -567,7 +562,7 @@
await tester.pumpWidget(widget);
// Select EditableText to give it focus.
- final Finder textFinder = find.byKey(editableTextKey);
+ final Finder textFinder = find.byType(EditableText);
await tester.tap(textFinder);
await tester.pump();
@@ -582,8 +577,6 @@
});
testWidgets('When "done" is pressed callbacks are invoked: onEditingComplete > onSubmitted', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
bool onEditingCompleteCalled = false;
@@ -592,7 +585,6 @@
final Widget widget = MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -610,7 +602,7 @@
await tester.pumpWidget(widget);
// Select EditableText to give it focus.
- final Finder textFinder = find.byKey(editableTextKey);
+ final Finder textFinder = find.byType(EditableText);
await tester.tap(textFinder);
await tester.pump();
@@ -625,8 +617,6 @@
});
testWidgets('When "next" is pressed callbacks are invoked: onEditingComplete > onSubmitted', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
bool onEditingCompleteCalled = false;
@@ -635,7 +625,6 @@
final Widget widget = MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -653,7 +642,7 @@
await tester.pumpWidget(widget);
// Select EditableText to give it focus.
- final Finder textFinder = find.byKey(editableTextKey);
+ final Finder textFinder = find.byType(EditableText);
await tester.tap(textFinder);
await tester.pump();
@@ -668,8 +657,6 @@
});
testWidgets('When "newline" action is called on a Editable text with maxLines == 1 callbacks are invoked: onEditingComplete > onSubmitted', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
bool onEditingCompleteCalled = false;
@@ -678,7 +665,6 @@
final Widget widget = MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -697,7 +683,7 @@
await tester.pumpWidget(widget);
// Select EditableText to give it focus.
- final Finder textFinder = find.byKey(editableTextKey);
+ final Finder textFinder = find.byType(EditableText);
await tester.tap(textFinder);
await tester.pump();
@@ -711,8 +697,6 @@
});
testWidgets('When "newline" action is called on a Editable text with maxLines != 1, onEditingComplete and onSubmitted callbacks are not invoked.', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
bool onEditingCompleteCalled = false;
@@ -721,7 +705,6 @@
final Widget widget = MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: TextEditingController(),
focusNode: focusNode,
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -738,7 +721,7 @@
await tester.pumpWidget(widget);
// Select EditableText to give it focus.
- final Finder textFinder = find.byKey(editableTextKey);
+ final Finder textFinder = find.byType(EditableText);
await tester.tap(textFinder);
await tester.pump();
@@ -754,8 +737,6 @@
});
testWidgets('Changing controller updates EditableText', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final TextEditingController controller1 =
TextEditingController(text: 'Wibble');
final TextEditingController controller2 =
@@ -775,7 +756,6 @@
child: Material(
child: EditableText(
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: currentController,
focusNode: FocusNode(),
style: Typography(platform: TargetPlatform.android)
@@ -1904,15 +1884,12 @@
composing: TextRange(start: 5, end: 14),
),
);
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(MaterialApp( // So we can show overlays.
home: EditableText(
autofocus: true,
backgroundCursorColor: Colors.grey,
- key: editableTextKey,
controller: controller,
focusNode: focusNode,
style: textStyle,
@@ -1943,19 +1920,16 @@
});
testWidgets('text selection handle visibility', (WidgetTester tester) async {
- final GlobalKey<EditableTextState> editableTextKey =
- GlobalKey<EditableTextState>();
-
+ // Text with two separate words to select.
const String testText = 'XXXXX XXXXX';
final TextEditingController controller = TextEditingController(text: testText);
- final Widget widget = MaterialApp(
+ await tester.pumpWidget(MaterialApp(
home: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 100,
child: EditableText(
- key: editableTextKey,
controller: controller,
focusNode: FocusNode(),
style: Typography(platform: TargetPlatform.android).black.subhead,
@@ -1966,77 +1940,83 @@
),
),
),
- );
-
- await tester.pumpWidget(widget);
+ ));
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Scrollable scrollable = tester.widget<Scrollable>(find.byType(Scrollable));
- bool leftVisibleBefore = false;
- bool rightVisibleBefore = false;
+ bool expectedLeftVisibleBefore = false;
+ bool expectedRightVisibleBefore = false;
Future<void> verifyVisibility(
- bool leftVisible,
- Symbol leftPosition,
- bool rightVisible,
- Symbol rightPosition,
+ HandlePositionInViewport leftPosition,
+ bool expectedLeftVisible,
+ HandlePositionInViewport rightPosition,
+ bool expectedRightVisible,
) async {
await tester.pump();
// Check the signal from RenderEditable about whether they're within the
// viewport.
- expect(renderEditable.selectionStartInViewport.value, equals(leftVisible));
- expect(renderEditable.selectionEndInViewport.value, equals(rightVisible));
+ expect(renderEditable.selectionStartInViewport.value, equals(expectedLeftVisible));
+ expect(renderEditable.selectionEndInViewport.value, equals(expectedRightVisible));
// Check that the animations are functional and going in the right
// direction.
final List<Widget> transitions =
find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
+ // On Android, an empty app contains a single FadeTransition. The following
+ // two are the left and right text selection handles, respectively.
final FadeTransition left = transitions[1];
final FadeTransition right = transitions[2];
- if (leftVisibleBefore)
+ if (expectedLeftVisibleBefore)
expect(left.opacity.value, equals(1.0));
- if (rightVisibleBefore)
+ if (expectedRightVisibleBefore)
expect(right.opacity.value, equals(1.0));
await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
- if (leftVisible != leftVisibleBefore)
+ if (expectedLeftVisible != expectedLeftVisibleBefore)
expect(left.opacity.value, equals(0.5));
- if (rightVisible != rightVisibleBefore)
+ if (expectedRightVisible != expectedRightVisibleBefore)
expect(right.opacity.value, equals(0.5));
await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
- if (leftVisible)
+ if (expectedLeftVisible)
expect(left.opacity.value, equals(1.0));
- if (rightVisible)
+ if (expectedRightVisible)
expect(right.opacity.value, equals(1.0));
- leftVisibleBefore = leftVisible;
- rightVisibleBefore = rightVisible;
+ expectedLeftVisibleBefore = expectedLeftVisible;
+ expectedRightVisibleBefore = expectedRightVisible;
- // Check that the handles' positions are correct (clamped within the
- // viewport but not stuck).
+ // Check that the handles' positions are correct.
final List<Positioned> positioned =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList();
final Size viewport = renderEditable.size;
- void testPosition(double pos, Symbol expected) {
- if (expected == #left)
- expect(pos, equals(0.0));
- if (expected == #right)
- expect(pos, equals(viewport.width));
- if (expected == #middle)
- expect(pos, inExclusiveRange(0.0, viewport.width));
+ void testPosition(double pos, HandlePositionInViewport expected) {
+ switch (expected) {
+ case HandlePositionInViewport.leftEdge:
+ expect(pos, equals(0.0));
+ break;
+ case HandlePositionInViewport.rightEdge:
+ expect(pos, equals(viewport.width));
+ break;
+ case HandlePositionInViewport.within:
+ expect(pos, inExclusiveRange(0.0, viewport.width));
+ break;
+ default:
+ throw TestFailure('HandlePositionInViewport can\'t be null.');
+ }
}
testPosition(positioned[0].left, leftPosition);
@@ -2047,38 +2027,224 @@
await tester.tapAt(const Offset(20, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
await tester.pump();
- await verifyVisibility(true, #left, true, #middle);
+ await verifyVisibility(HandlePositionInViewport.leftEdge, true, HandlePositionInViewport.within, true);
// Drag the text slightly so the first word is partially visible. Only the
// right handle should be visible.
scrollable.controller.jumpTo(20.0);
- await verifyVisibility(false, #left, true, #middle);
+ await verifyVisibility(HandlePositionInViewport.leftEdge, false, HandlePositionInViewport.within, true);
// Drag the text all the way to the left so the first word is not visible at
// all (and the second word is fully visible). Both handles should be
// invisible now.
scrollable.controller.jumpTo(200.0);
- await verifyVisibility(false, #left, false, #left);
+ await verifyVisibility(HandlePositionInViewport.leftEdge, false, HandlePositionInViewport.leftEdge, false);
// Tap to unselect.
- await tester.tap(find.byKey(editableTextKey));
+ await tester.tap(find.byType(EditableText));
await tester.pump();
// Now that the second word has been dragged fully into view, select it.
await tester.tapAt(const Offset(80, 10));
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
await tester.pump();
- await verifyVisibility(true, #middle, true, #middle);
+ await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.within, true);
// Drag the text slightly to the right. Only the left handle should be
// visible.
scrollable.controller.jumpTo(150);
- await verifyVisibility(true, #middle, false, #right);
+ await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.rightEdge, false);
// Drag the text all the way to the right, so the second word is not visible
// at all. Again, both handles should be invisible.
scrollable.controller.jumpTo(0);
- await verifyVisibility(false, #right, false, #right);
+ await verifyVisibility(HandlePositionInViewport.rightEdge, false, HandlePositionInViewport.rightEdge, false);
+ });
+
+ testWidgets('text selection handle visibility RTL', (WidgetTester tester) async {
+ // Text with two separate words to select.
+ const String testText = 'XXXXX XXXXX';
+ final TextEditingController controller = TextEditingController(text: testText);
+
+ await tester.pumpWidget(MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 100,
+ child: EditableText(
+ controller: controller,
+ focusNode: FocusNode(),
+ style: Typography(platform: TargetPlatform.android).black.subhead,
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ selectionControls: materialTextSelectionControls,
+ keyboardType: TextInputType.text,
+ textAlign: TextAlign.right,
+ ),
+ ),
+ ),
+ ));
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+
+ // Select the first word. Both handles should be visible.
+ await tester.tapAt(const Offset(20, 10));
+ state.renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pump();
+ final List<Positioned> positioned =
+ find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList();
+ expect(positioned[0].left, 0.0);
+ expect(positioned[1].left, 70.0);
+ expect(controller.selection.base.offset, 0);
+ expect(controller.selection.extent.offset, 5);
+ });
+
+ // Regression test for https://github.com/flutter/flutter/issues/31287
+ testWidgets('iOS text selection handle visibility', (WidgetTester tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+
+ // Text with two separate words to select.
+ const String testText = 'XXXXX XXXXX';
+ final TextEditingController controller = TextEditingController(text: testText);
+
+ await tester.pumpWidget(MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: Container(
+ child: SizedBox(
+ width: 100,
+ child: EditableText(
+ controller: controller,
+ focusNode: FocusNode(),
+ style: Typography(platform: TargetPlatform.iOS).black.subhead,
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ selectionControls: cupertinoTextSelectionControls,
+ keyboardType: TextInputType.text,
+ ),
+ ),
+ ),
+ ),
+ ));
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ final RenderEditable renderEditable = state.renderEditable;
+ final Scrollable scrollable = tester.widget<Scrollable>(find.byType(Scrollable));
+
+ bool expectedLeftVisibleBefore = false;
+ bool expectedRightVisibleBefore = false;
+
+ Future<void> verifyVisibility(
+ HandlePositionInViewport leftPosition,
+ bool expectedLeftVisible,
+ HandlePositionInViewport rightPosition,
+ bool expectedRightVisible,
+ ) async {
+ await tester.pump();
+
+ // Check the signal from RenderEditable about whether they're within the
+ // viewport.
+
+ expect(renderEditable.selectionStartInViewport.value, equals(expectedLeftVisible));
+ expect(renderEditable.selectionEndInViewport.value, equals(expectedRightVisible));
+
+ // Check that the animations are functional and going in the right
+ // direction.
+
+ final List<Widget> transitions =
+ find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList();
+ final FadeTransition left = transitions[0];
+ final FadeTransition right = transitions[1];
+
+ if (expectedLeftVisibleBefore)
+ expect(left.opacity.value, equals(1.0));
+ if (expectedRightVisibleBefore)
+ expect(right.opacity.value, equals(1.0));
+
+ await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
+
+ if (expectedLeftVisible != expectedLeftVisibleBefore)
+ expect(left.opacity.value, equals(0.5));
+ if (expectedRightVisible != expectedRightVisibleBefore)
+ expect(right.opacity.value, equals(0.5));
+
+ await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2);
+
+ if (expectedLeftVisible)
+ expect(left.opacity.value, equals(1.0));
+ if (expectedRightVisible)
+ expect(right.opacity.value, equals(1.0));
+
+ expectedLeftVisibleBefore = expectedLeftVisible;
+ expectedRightVisibleBefore = expectedRightVisible;
+
+ // Check that the handles' positions are correct.
+
+ final List<Positioned> positioned =
+ find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().toList();
+
+ final Size viewport = renderEditable.size;
+
+ void testPosition(double pos, HandlePositionInViewport expected) {
+ switch (expected) {
+ case HandlePositionInViewport.leftEdge:
+ expect(pos, equals(0.0));
+ break;
+ case HandlePositionInViewport.rightEdge:
+ expect(pos, equals(viewport.width));
+ break;
+ case HandlePositionInViewport.within:
+ expect(pos, inExclusiveRange(0.0, viewport.width));
+ break;
+ default:
+ throw TestFailure('HandlePositionInViewport can\'t be null.');
+ }
+ }
+
+ testPosition(positioned[1].left, leftPosition);
+ testPosition(positioned[2].left, rightPosition);
+ }
+
+ // Select the first word. Both handles should be visible.
+ await tester.tapAt(const Offset(20, 10));
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pump();
+ await verifyVisibility(HandlePositionInViewport.leftEdge, true, HandlePositionInViewport.within, true);
+
+ // Drag the text slightly so the first word is partially visible. Only the
+ // right handle should be visible.
+ scrollable.controller.jumpTo(20.0);
+ await verifyVisibility(HandlePositionInViewport.leftEdge, false, HandlePositionInViewport.within, true);
+
+ // Drag the text all the way to the left so the first word is not visible at
+ // all (and the second word is fully visible). Both handles should be
+ // invisible now.
+ scrollable.controller.jumpTo(200.0);
+ await verifyVisibility(HandlePositionInViewport.leftEdge, false, HandlePositionInViewport.leftEdge, false);
+
+ // Tap to unselect.
+ await tester.tap(find.byType(EditableText));
+ await tester.pump();
+
+ // Now that the second word has been dragged fully into view, select it.
+ await tester.tapAt(const Offset(80, 10));
+ renderEditable.selectWord(cause: SelectionChangedCause.longPress);
+ await tester.pump();
+ await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.within, true);
+
+ // Drag the text slightly to the right. Only the left handle should be
+ // visible.
+ scrollable.controller.jumpTo(150);
+ await verifyVisibility(HandlePositionInViewport.within, true, HandlePositionInViewport.rightEdge, false);
+
+ // Drag the text all the way to the right, so the second word is not visible
+ // at all. Again, both handles should be invisible.
+ scrollable.controller.jumpTo(0);
+ await verifyVisibility(HandlePositionInViewport.rightEdge, false, HandlePositionInViewport.rightEdge, false);
+
+ debugDefaultTargetPlatformOverride = null;
});
}