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;
   });
 }