Revert "Disable cursor opacity animation on macOS, make iOS cursor animation discrete (#104335)" (#106762) (#106766)

Co-authored-by: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com>
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index a7abaa8..451cf66 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -1168,7 +1168,7 @@
         forcePressEnabled = false;
         textSelectionControls ??= cupertinoDesktopTextSelectionControls;
         paintCursorAboveText = true;
-        cursorOpacityAnimates = false;
+        cursorOpacityAnimates = true;
         cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
         selectionColor = selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
         cursorRadius ??= const Radius.circular(2.0);
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 640a7c1..9e83244 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -52,6 +52,10 @@
 // to transparent, is twice this duration.
 const Duration _kCursorBlinkHalfPeriod = Duration(milliseconds: 500);
 
+// The time the cursor is static in opacity before animating to become
+// transparent.
+const Duration _kCursorBlinkWaitForStart = Duration(milliseconds: 150);
+
 // Number of cursor ticks during which the most recently entered character
 // is shown in an obscured text field.
 const int _kObscureShowLatestCharCursorTicks = 3;
@@ -297,91 +301,6 @@
   final bool selectAll;
 }
 
-// A time-value pair that represents a key frame in an animation.
-class _KeyFrame {
-  const _KeyFrame(this.time, this.value);
-  // Values extracted from iOS 15.4 UIKit.
-  static const List<_KeyFrame> iOSBlinkingCaretKeyFrames = <_KeyFrame>[
-    _KeyFrame(0,       1),     // 0
-    _KeyFrame(0.5,     1),     // 1
-    _KeyFrame(0.5375,  0.75),  // 2
-    _KeyFrame(0.575,   0.5),   // 3
-    _KeyFrame(0.6125,  0.25),  // 4
-    _KeyFrame(0.65,    0),     // 5
-    _KeyFrame(0.85,    0),     // 6
-    _KeyFrame(0.8875,  0.25),  // 7
-    _KeyFrame(0.925,   0.5),   // 8
-    _KeyFrame(0.9625,  0.75),  // 9
-    _KeyFrame(1,       1),     // 10
-  ];
-
-  // The timing, in seconds, of the specified animation `value`.
-  final double time;
-  final double value;
-}
-
-class _DiscreteKeyFrameSimulation extends Simulation {
-  _DiscreteKeyFrameSimulation.iOSBlinkingCaret() : this._(_KeyFrame.iOSBlinkingCaretKeyFrames, 1);
-  _DiscreteKeyFrameSimulation._(this._keyFrames, this.maxDuration)
-    : assert(_keyFrames.isNotEmpty),
-      assert(_keyFrames.last.time <= maxDuration),
-      assert(() {
-        for (int i = 0; i < _keyFrames.length -1; i += 1) {
-          if (_keyFrames[i].time > _keyFrames[i + 1].time) {
-            return false;
-          }
-        }
-        return true;
-      }(), 'The key frame sequence must be sorted by time.');
-
-  final double maxDuration;
-
-  final List<_KeyFrame> _keyFrames;
-
-  @override
-  double dx(double time) => 0;
-
-  @override
-  bool isDone(double time) => time >= maxDuration;
-
-  // The index of the KeyFrame corresponds to the most recent input `time`.
-  int _lastKeyFrameIndex = 0;
-
-  @override
-  double x(double time) {
-    final int length = _keyFrames.length;
-
-    // Perform a linear search in the sorted key frame list, starting from the
-    // last key frame found, since the input `time` usually monotonically
-    // increases by a small amount.
-    int searchIndex;
-    final int endIndex;
-    if (_keyFrames[_lastKeyFrameIndex].time > time) {
-      // The simulation may have restarted. Search within the index range
-      // [0, _lastKeyFrameIndex).
-      searchIndex = 0;
-      endIndex = _lastKeyFrameIndex;
-    } else {
-      searchIndex = _lastKeyFrameIndex;
-      endIndex = length;
-    }
-
-    // Find the target key frame. Don't have to check (endIndex - 1): if
-    // (endIndex - 2) doesn't work we'll have to pick (endIndex - 1) anyways.
-    while (searchIndex < endIndex - 1) {
-      assert(_keyFrames[searchIndex].time <= time);
-      final _KeyFrame next = _keyFrames[searchIndex + 1];
-      if (time < next.time) {
-        break;
-      }
-      searchIndex += 1;
-    }
-
-    _lastKeyFrameIndex = searchIndex;
-    return _keyFrames[_lastKeyFrameIndex].value;
-  }
-}
-
 /// A basic text input field.
 ///
 /// This widget interacts with the [TextInput] service to let the user edit the
@@ -1678,14 +1597,7 @@
 /// State for a [EditableText].
 class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate implements TextInputClient, AutofillClient {
   Timer? _cursorTimer;
-  AnimationController get _cursorBlinkOpacityController {
-    return _backingCursorBlinkOpacityController ??= AnimationController(
-      vsync: this,
-    )..addListener(_onCursorColorTick);
-  }
-  AnimationController? _backingCursorBlinkOpacityController;
-  late final Simulation _iosBlinkCursorSimulation = _DiscreteKeyFrameSimulation.iOSBlinkingCaret();
-
+  bool _targetCursorVisibility = false;
   final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
   final GlobalKey _editableKey = GlobalKey();
   final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
@@ -1696,6 +1608,8 @@
   ScrollController? _internalScrollController;
   ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
 
+  AnimationController? _cursorBlinkOpacityController;
+
   final LayerLink _toolbarLayerLink = LayerLink();
   final LayerLink _startHandleLayerLink = LayerLink();
   final LayerLink _endHandleLayerLink = LayerLink();
@@ -1723,6 +1637,10 @@
   /// - Changing the selection using a physical keyboard.
   bool get _shouldCreateInputConnection => kIsWeb || !widget.readOnly;
 
+  // This value is an eyeball estimation of the time it takes for the iOS cursor
+  // to ease in and out.
+  static const Duration _fadeDuration = Duration(milliseconds: 250);
+
   // The time it takes for the floating cursor to snap to the text aligned
   // cursor position after the user has finished placing it.
   static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
@@ -1734,7 +1652,7 @@
   @override
   bool get wantKeepAlive => widget.focusNode.hasFocus;
 
-  Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
+  Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
 
   @override
   bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
@@ -1888,6 +1806,10 @@
   @override
   void initState() {
     super.initState();
+    _cursorBlinkOpacityController = AnimationController(
+      vsync: this,
+      duration: _fadeDuration,
+    )..addListener(_onCursorColorTick);
     _clipboardStatus?.addListener(_onChangedClipboardStatus);
     widget.controller.addListener(_didChangeTextEditingValue);
     widget.focusNode.addListener(_handleFocusChanged);
@@ -1924,7 +1846,7 @@
     if (_tickersEnabled != newTickerEnabled) {
       _tickersEnabled = newTickerEnabled;
       if (_tickersEnabled && _cursorActive) {
-        _startCursorBlink();
+        _startCursorTimer();
       } else if (!_tickersEnabled && _cursorTimer != null) {
         // Cannot use _stopCursorTimer because it would reset _cursorActive.
         _cursorTimer!.cancel();
@@ -2024,8 +1946,8 @@
     assert(!_hasInputConnection);
     _cursorTimer?.cancel();
     _cursorTimer = null;
-    _backingCursorBlinkOpacityController?.dispose();
-    _backingCursorBlinkOpacityController = null;
+    _cursorBlinkOpacityController?.dispose();
+    _cursorBlinkOpacityController = null;
     _selectionOverlay?.dispose();
     _selectionOverlay = null;
     widget.focusNode.removeListener(_handleFocusChanged);
@@ -2104,8 +2026,8 @@
     if (_hasInputConnection) {
       // To keep the cursor from blinking while typing, we want to restart the
       // cursor timer every time a new character is typed.
-      _stopCursorBlink(resetCharTicks: false);
-      _startCursorBlink();
+      _stopCursorTimer(resetCharTicks: false);
+      _startCursorTimer();
     }
   }
 
@@ -2626,8 +2548,8 @@
 
     // To keep the cursor from blinking while it moves, restart the timer here.
     if (_cursorTimer != null) {
-      _stopCursorBlink(resetCharTicks: false);
-      _startCursorBlink();
+      _stopCursorTimer(resetCharTicks: false);
+      _startCursorTimer();
     }
   }
 
@@ -2781,14 +2703,14 @@
   }
 
   void _onCursorColorTick() {
-    renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
-    _cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController.value > 0;
+    renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
+    _cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController!.value > 0;
   }
 
   /// Whether the blinking cursor is actually visible at this precise moment
   /// (it's hidden half the time, since it blinks).
   @visibleForTesting
-  bool get cursorCurrentlyVisible => _cursorBlinkOpacityController.value > 0;
+  bool get cursorCurrentlyVisible => _cursorBlinkOpacityController!.value > 0;
 
   /// The cursor blink interval (the amount of time the cursor is in the "on"
   /// state or the "off" state). A complete cursor blink period is twice this
@@ -2803,69 +2725,83 @@
   int _obscureShowCharTicksPending = 0;
   int? _obscureLatestCharIndex;
 
+  void _cursorTick(Timer timer) {
+    _targetCursorVisibility = !_targetCursorVisibility;
+    final double targetOpacity = _targetCursorVisibility ? 1.0 : 0.0;
+    if (widget.cursorOpacityAnimates) {
+      // If we want to show the cursor, we will animate the opacity to the value
+      // of 1.0, and likewise if we want to make it disappear, to 0.0. An easing
+      // curve is used for the animation to mimic the aesthetics of the native
+      // iOS cursor.
+      //
+      // These values and curves have been obtained through eyeballing, so are
+      // likely not exactly the same as the values for native iOS.
+      _cursorBlinkOpacityController!.animateTo(targetOpacity, curve: Curves.easeOut);
+    } else {
+      _cursorBlinkOpacityController!.value = targetOpacity;
+    }
+
+    if (_obscureShowCharTicksPending > 0) {
+      setState(() {
+        _obscureShowCharTicksPending = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
+          ? _obscureShowCharTicksPending - 1
+          : 0;
+      });
+    }
+  }
+
+  void _cursorWaitForStart(Timer timer) {
+    assert(_kCursorBlinkHalfPeriod > _fadeDuration);
+    assert(!EditableText.debugDeterministicCursor);
+    _cursorTimer?.cancel();
+    _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
+  }
+
   // Indicates whether the cursor should be blinking right now (but it may
   // actually not blink because it's disabled via TickerMode.of(context)).
   bool _cursorActive = false;
 
-  void _startCursorBlink() {
-    assert(!(_cursorTimer?.isActive ?? false) || !(_backingCursorBlinkOpacityController?.isAnimating ?? false));
+  void _startCursorTimer() {
+    assert(_cursorTimer == null);
     _cursorActive = true;
     if (!_tickersEnabled) {
       return;
     }
-    _cursorTimer?.cancel();
-    _cursorBlinkOpacityController.value = 1.0;
+    _targetCursorVisibility = true;
+    _cursorBlinkOpacityController!.value = 1.0;
     if (EditableText.debugDeterministicCursor) {
       return;
     }
     if (widget.cursorOpacityAnimates) {
-      _cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick);
+      _cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart);
     } else {
-      _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
+      _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
     }
   }
 
-  void _onCursorTick() {
-    if (_obscureShowCharTicksPending > 0) {
-      _obscureShowCharTicksPending = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
-        ? _obscureShowCharTicksPending - 1
-        : 0;
-      if (_obscureShowCharTicksPending == 0) {
-        setState(() { });
-      }
-    }
-
-    if (widget.cursorOpacityAnimates) {
-      _cursorTimer?.cancel();
-      // Schedule this as an async task to avoid blocking tester.pumpAndSettle
-      // indefinitely.
-      _cursorTimer = Timer(Duration.zero, () => _cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick));
-    } else {
-      if (!(_cursorTimer?.isActive ?? false) && _tickersEnabled) {
-        _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
-      }
-      _cursorBlinkOpacityController.value = _cursorBlinkOpacityController.value == 0 ? 1 : 0;
-    }
-  }
-
-  void _stopCursorBlink({ bool resetCharTicks = true }) {
+  void _stopCursorTimer({ bool resetCharTicks = true }) {
     _cursorActive = false;
-    _cursorBlinkOpacityController.value = 0.0;
+    _cursorTimer?.cancel();
+    _cursorTimer = null;
+    _targetCursorVisibility = false;
+    _cursorBlinkOpacityController!.value = 0.0;
     if (EditableText.debugDeterministicCursor) {
       return;
     }
-    _cursorBlinkOpacityController.value = 0.0;
     if (resetCharTicks) {
       _obscureShowCharTicksPending = 0;
     }
+    if (widget.cursorOpacityAnimates) {
+      _cursorBlinkOpacityController!.stop();
+      _cursorBlinkOpacityController!.value = 0.0;
+    }
   }
 
   void _startOrStopCursorTimerIfNeeded() {
     if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) {
-      _startCursorBlink();
-    }
-    else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
-      _stopCursorBlink();
+      _startCursorTimer();
+    } else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
+      _stopCursorTimer();
     }
   }
 
@@ -3552,10 +3488,8 @@
       String text = _value.text;
       text = widget.obscuringCharacter * text.length;
       // Reveal the latest character in an obscured field only on mobile.
-      // Newer verions of iOS (iOS 15+) no longer reveal the most recently
-      // entered character.
       const Set<TargetPlatform> mobilePlatforms = <TargetPlatform> {
-        TargetPlatform.android, TargetPlatform.fuchsia,
+        TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia,
       };
       final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
                                     && mobilePlatforms.contains(defaultTargetPlatform);
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index 5a1e68d..7b05898 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -702,6 +702,40 @@
     expect(editableText.cursorOffset, const Offset(-2.0 / 3.0, 0));
   });
 
+  testWidgets('Cursor animates', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      const CupertinoApp(
+        home: CupertinoTextField(),
+      ),
+    );
+
+    final Finder textFinder = find.byType(CupertinoTextField);
+    await tester.tap(textFinder);
+    await tester.pump();
+
+    final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
+    final RenderEditable renderEditable = editableTextState.renderEditable;
+
+    expect(renderEditable.cursorColor!.alpha, 255);
+
+    await tester.pump(const Duration(milliseconds: 100));
+    await tester.pump(const Duration(milliseconds: 400));
+
+    expect(renderEditable.cursorColor!.alpha, 255);
+
+    await tester.pump(const Duration(milliseconds: 200));
+    await tester.pump(const Duration(milliseconds: 100));
+
+    expect(renderEditable.cursorColor!.alpha, 110);
+
+    await tester.pump(const Duration(milliseconds: 100));
+
+    expect(renderEditable.cursorColor!.alpha, 16);
+    await tester.pump(const Duration(milliseconds: 50));
+
+    expect(renderEditable.cursorColor!.alpha, 0);
+  }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
+
   testWidgets('Cursor radius is 2.0', (WidgetTester tester) async {
     await tester.pumpWidget(
       const CupertinoApp(
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 517bd67..0b146ad 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -609,6 +609,42 @@
     await checkCursorToggle();
   });
 
+  testWidgets('Cursor animates', (WidgetTester tester) async {
+    await tester.pumpWidget(
+      const MaterialApp(
+        home: Material(
+          child: TextField(),
+        ),
+      ),
+    );
+
+    final Finder textFinder = find.byType(TextField);
+    await tester.tap(textFinder);
+    await tester.pump();
+
+    final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
+    final RenderEditable renderEditable = editableTextState.renderEditable;
+
+    expect(renderEditable.cursorColor!.alpha, 255);
+
+    await tester.pump(const Duration(milliseconds: 100));
+    await tester.pump(const Duration(milliseconds: 400));
+
+    expect(renderEditable.cursorColor!.alpha, 255);
+
+    await tester.pump(const Duration(milliseconds: 200));
+    await tester.pump(const Duration(milliseconds: 100));
+
+    expect(renderEditable.cursorColor!.alpha, 110);
+
+    await tester.pump(const Duration(milliseconds: 100));
+
+    expect(renderEditable.cursorColor!.alpha, 16);
+    await tester.pump(const Duration(milliseconds: 50));
+
+    expect(renderEditable.cursorColor!.alpha, 0);
+  }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
+
   // 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');
@@ -1292,7 +1328,7 @@
 
     editText = (findRenderEditable(tester).text! as TextSpan).text!;
     expect(editText.substring(editText.length - 1), '\u2022');
-  }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android }));
+  }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }));
 
   testWidgets('desktop obscureText control test', (WidgetTester tester) async {
     await tester.pumpWidget(
diff --git a/packages/flutter/test/widgets/editable_text_cursor_test.dart b/packages/flutter/test/widgets/editable_text_cursor_test.dart
index 99c34cb..0ae6295 100644
--- a/packages/flutter/test/widgets/editable_text_cursor_test.dart
+++ b/packages/flutter/test/widgets/editable_text_cursor_test.dart
@@ -166,75 +166,61 @@
     );
   });
 
-  testWidgets('Cursor animates on iOS', (WidgetTester tester) async {
-    await tester.pumpWidget(
-      const MaterialApp(
-        home: Material(
-          child: TextField(),
+  testWidgets('Cursor animates', (WidgetTester tester) async {
+    const Widget widget = MaterialApp(
+      home: Material(
+        child: TextField(
+          maxLines: 3,
         ),
       ),
     );
+    await tester.pumpWidget(widget);
 
-    final Finder textFinder = find.byType(TextField);
-    await tester.tap(textFinder);
+    await tester.tap(find.byType(TextField));
     await tester.pump();
 
     final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
     final RenderEditable renderEditable = editableTextState.renderEditable;
 
-    expect(renderEditable.cursorColor!.opacity, 1.0);
+    expect(renderEditable.cursorColor!.alpha, 255);
 
-    int walltimeMicrosecond = 0;
-    double lastVerifiedOpacity = 1.0;
-
-    Future<void> verifyKeyFrame({ required double opacity, required int at }) async {
-      const int delta = 1;
-      assert(at - delta > walltimeMicrosecond);
-      await tester.pump(Duration(microseconds: at - delta - walltimeMicrosecond));
-
-      // Instead of verifying the opacity at each key frame, this function
-      // verifies the opacity immediately *before* each key frame to avoid
-      // fp precision issues.
-      expect(
-        renderEditable.cursorColor!.opacity,
-        closeTo(lastVerifiedOpacity, 0.01),
-        reason: 'opacity at ${at-delta} microseconds',
-      );
-
-      walltimeMicrosecond = at - delta;
-      lastVerifiedOpacity = opacity;
-    }
-
-    await verifyKeyFrame(opacity: 1.0,  at: 500000);
-    await verifyKeyFrame(opacity: 0.75, at: 537500);
-    await verifyKeyFrame(opacity: 0.5,  at: 575000);
-    await verifyKeyFrame(opacity: 0.25, at: 612500);
-    await verifyKeyFrame(opacity: 0.0,  at: 650000);
-    await verifyKeyFrame(opacity: 0.0,  at: 850000);
-    await verifyKeyFrame(opacity: 0.25, at: 887500);
-    await verifyKeyFrame(opacity: 0.5,  at: 925000);
-    await verifyKeyFrame(opacity: 0.75, at: 962500);
-    await verifyKeyFrame(opacity: 1.0,  at: 1000000);
-  }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
-
-  testWidgets('Cursor does not animate on non-iOS platforms', (WidgetTester tester) async {
-    await tester.pumpWidget(
-      const MaterialApp(
-        home: Material(child: TextField(maxLines: 3)),
-      ),
-    );
-
-    await tester.tap(find.byType(TextField));
+    // Trigger initial timer. When focusing the first time, the cursor shows
+    // for slightly longer than the average on time.
     await tester.pump();
-    // Wait for the current animation to finish. If the cursor never stops its
-    // blinking animation the test will timeout.
-    await tester.pumpAndSettle();
+    await tester.pump(const Duration(milliseconds: 200));
+    // Start timing standard cursor show period.
+    expect(renderEditable.cursorColor!.alpha, 255);
+    expect(renderEditable, paints..rrect(color: const Color(0xff2196f3)));
 
-    for (int i = 0; i < 40; i += 1) {
-      await tester.pump(const Duration(milliseconds: 100));
-      expect(tester.hasRunningAnimations, false);
-    }
-  }, variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }));
+    await tester.pump(const Duration(milliseconds: 500));
+    // Start to animate the cursor away.
+    expect(renderEditable.cursorColor!.alpha, 255);
+    expect(renderEditable, paints..rrect(color: const Color(0xff2196f3)));
+
+    await tester.pump(const Duration(milliseconds: 100));
+    expect(renderEditable.cursorColor!.alpha, 110);
+    expect(renderEditable, paints..rrect(color: const Color(0x6e2196f3)));
+
+    await tester.pump(const Duration(milliseconds: 100));
+    expect(renderEditable.cursorColor!.alpha, 16);
+    expect(renderEditable, paints..rrect(color: const Color(0x102196f3)));
+
+    await tester.pump(const Duration(milliseconds: 100));
+    expect(renderEditable.cursorColor!.alpha, 0);
+    // Don't try to draw the cursor.
+    expect(renderEditable, paintsExactlyCountTimes(#drawRRect, 0));
+
+    // Wait some more while the cursor is gone. It'll trigger the cursor to
+    // start animating in again.
+    await tester.pump(const Duration(milliseconds: 300));
+    expect(renderEditable.cursorColor!.alpha, 0);
+    expect(renderEditable, paintsExactlyCountTimes(#drawRRect, 0));
+
+    await tester.pump(const Duration(milliseconds: 50));
+    // Cursor starts coming back.
+    expect(renderEditable.cursorColor!.alpha, 79);
+    expect(renderEditable, paints..rrect(color: const Color(0x4f2196f3)));
+  }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
 
   testWidgets('Cursor does not animate on Android', (WidgetTester tester) async {
     final Color defaultCursorColor = Color(ThemeData.fallback().colorScheme.primary.value);
@@ -970,40 +956,4 @@
     );
     EditableText.debugDeterministicCursor = false;
   }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS,  TargetPlatform.macOS }));
-
-  testWidgets('password briefly does not show last character when disabled by system', (WidgetTester tester) async {
-    final bool debugDeterministicCursor = EditableText.debugDeterministicCursor;
-    EditableText.debugDeterministicCursor = false;
-    addTearDown(() {
-      EditableText.debugDeterministicCursor = debugDeterministicCursor;
-    });
-
-    await tester.pumpWidget(MaterialApp(
-      home: EditableText(
-        backgroundCursorColor: Colors.grey,
-        controller: controller,
-        obscureText: true,
-        focusNode: focusNode,
-        style: textStyle,
-        cursorColor: cursorColor,
-      ),
-    ));
-
-    await tester.enterText(find.byType(EditableText), 'AA');
-    await tester.pump();
-    await tester.enterText(find.byType(EditableText), 'AAA');
-    await tester.pump();
-
-    tester.binding.platformDispatcher.brieflyShowPasswordTestValue = false;
-    addTearDown(() {
-      tester.binding.platformDispatcher.brieflyShowPasswordTestValue = true;
-    });
-    expect((findRenderEditable(tester).text! as TextSpan).text, '••A');
-    await tester.pump(const Duration(milliseconds: 500));
-    expect((findRenderEditable(tester).text! as TextSpan).text, '•••');
-    await tester.pump(const Duration(milliseconds: 500));
-    await tester.pump(const Duration(milliseconds: 500));
-    await tester.pump(const Duration(milliseconds: 500));
-    expect((findRenderEditable(tester).text! as TextSpan).text, '•••');
-  });
 }
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 55acff9..3e46034 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -3943,6 +3943,42 @@
     expect((findRenderEditable(tester).text! as TextSpan).text, '•••');
   });
 
+  testWidgets('password briefly does not show last character on Android if turned off', (WidgetTester tester) async {
+    final bool debugDeterministicCursor = EditableText.debugDeterministicCursor;
+    EditableText.debugDeterministicCursor = false;
+    addTearDown(() {
+      EditableText.debugDeterministicCursor = debugDeterministicCursor;
+    });
+
+    await tester.pumpWidget(MaterialApp(
+      home: EditableText(
+        backgroundCursorColor: Colors.grey,
+        controller: controller,
+        obscureText: true,
+        focusNode: focusNode,
+        style: textStyle,
+        cursorColor: cursorColor,
+      ),
+    ));
+
+    await tester.enterText(find.byType(EditableText), 'AA');
+    await tester.pump();
+    await tester.enterText(find.byType(EditableText), 'AAA');
+    await tester.pump();
+
+    tester.binding.platformDispatcher.brieflyShowPasswordTestValue = false;
+    addTearDown(() {
+      tester.binding.platformDispatcher.brieflyShowPasswordTestValue = true;
+    });
+    expect((findRenderEditable(tester).text! as TextSpan).text, '••A');
+    await tester.pump(const Duration(milliseconds: 500));
+    expect((findRenderEditable(tester).text! as TextSpan).text, '•••');
+    await tester.pump(const Duration(milliseconds: 500));
+    await tester.pump(const Duration(milliseconds: 500));
+    await tester.pump(const Duration(milliseconds: 500));
+    expect((findRenderEditable(tester).text! as TextSpan).text, '•••');
+  });
+
   group('a11y copy/cut/paste', () {
     Future<void> buildApp(MockTextSelectionControls controls, WidgetTester tester) {
       return tester.pumpWidget(MaterialApp(