diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 451cf665a7e0..a7abaa8a068d 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -1168,7 +1168,7 @@ class _TextFieldState extends State with RestorationMixin implements forcePressEnabled = false; textSelectionControls ??= cupertinoDesktopTextSelectionControls; paintCursorAboveText = true; - cursorOpacityAnimates = true; + cursorOpacityAnimates = false; 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 9e832448dd35..640a7c1573a4 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -52,10 +52,6 @@ typedef AppPrivateCommandCallback = void Function(String, Map); // 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; @@ -301,6 +297,91 @@ class ToolbarOptions { 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 @@ -1597,7 +1678,14 @@ class EditableText extends StatefulWidget { /// State for a [EditableText]. class EditableTextState extends State with AutomaticKeepAliveClientMixin, WidgetsBindingObserver, TickerProviderStateMixin, TextSelectionDelegate implements TextInputClient, AutofillClient { Timer? _cursorTimer; - bool _targetCursorVisibility = false; + AnimationController get _cursorBlinkOpacityController { + return _backingCursorBlinkOpacityController ??= AnimationController( + vsync: this, + )..addListener(_onCursorColorTick); + } + AnimationController? _backingCursorBlinkOpacityController; + late final Simulation _iosBlinkCursorSimulation = _DiscreteKeyFrameSimulation.iOSBlinkingCaret(); + final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(true); final GlobalKey _editableKey = GlobalKey(); final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier(); @@ -1608,8 +1696,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien ScrollController? _internalScrollController; ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController()); - AnimationController? _cursorBlinkOpacityController; - final LayerLink _toolbarLayerLink = LayerLink(); final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink(); @@ -1637,10 +1723,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// - 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); @@ -1652,7 +1734,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien @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; @@ -1806,10 +1888,6 @@ class EditableTextState extends State with AutomaticKeepAliveClien @override void initState() { super.initState(); - _cursorBlinkOpacityController = AnimationController( - vsync: this, - duration: _fadeDuration, - )..addListener(_onCursorColorTick); _clipboardStatus?.addListener(_onChangedClipboardStatus); widget.controller.addListener(_didChangeTextEditingValue); widget.focusNode.addListener(_handleFocusChanged); @@ -1846,7 +1924,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (_tickersEnabled != newTickerEnabled) { _tickersEnabled = newTickerEnabled; if (_tickersEnabled && _cursorActive) { - _startCursorTimer(); + _startCursorBlink(); } else if (!_tickersEnabled && _cursorTimer != null) { // Cannot use _stopCursorTimer because it would reset _cursorActive. _cursorTimer!.cancel(); @@ -1946,8 +2024,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien assert(!_hasInputConnection); _cursorTimer?.cancel(); _cursorTimer = null; - _cursorBlinkOpacityController?.dispose(); - _cursorBlinkOpacityController = null; + _backingCursorBlinkOpacityController?.dispose(); + _backingCursorBlinkOpacityController = null; _selectionOverlay?.dispose(); _selectionOverlay = null; widget.focusNode.removeListener(_handleFocusChanged); @@ -2026,8 +2104,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (_hasInputConnection) { // To keep the cursor from blinking while typing, we want to restart the // cursor timer every time a new character is typed. - _stopCursorTimer(resetCharTicks: false); - _startCursorTimer(); + _stopCursorBlink(resetCharTicks: false); + _startCursorBlink(); } } @@ -2548,8 +2626,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien // To keep the cursor from blinking while it moves, restart the timer here. if (_cursorTimer != null) { - _stopCursorTimer(resetCharTicks: false); - _startCursorTimer(); + _stopCursorBlink(resetCharTicks: false); + _startCursorBlink(); } } @@ -2703,14 +2781,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien } 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 @@ -2725,83 +2803,69 @@ class EditableTextState extends State with AutomaticKeepAliveClien 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 _startCursorTimer() { - assert(_cursorTimer == null); + void _startCursorBlink() { + assert(!(_cursorTimer?.isActive ?? false) || !(_backingCursorBlinkOpacityController?.isAnimating ?? false)); _cursorActive = true; if (!_tickersEnabled) { return; } - _targetCursorVisibility = true; - _cursorBlinkOpacityController!.value = 1.0; + _cursorTimer?.cancel(); + _cursorBlinkOpacityController.value = 1.0; if (EditableText.debugDeterministicCursor) { return; } if (widget.cursorOpacityAnimates) { - _cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart); + _cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick); } else { - _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); + _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); }); } } - void _stopCursorTimer({ bool resetCharTicks = true }) { + 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 }) { _cursorActive = false; - _cursorTimer?.cancel(); - _cursorTimer = null; - _targetCursorVisibility = false; - _cursorBlinkOpacityController!.value = 0.0; + _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) { - _startCursorTimer(); - } else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) { - _stopCursorTimer(); + _startCursorBlink(); + } + else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) { + _stopCursorBlink(); } } @@ -3488,8 +3552,10 @@ class EditableTextState extends State with AutomaticKeepAliveClien 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 mobilePlatforms = { - TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia, + TargetPlatform.android, 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 7b058980c082..5a1e68dc9279 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -702,40 +702,6 @@ void main() { 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.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 0b146ada68ad..517bd6796aa1 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -609,42 +609,6 @@ void main() { 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.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'); @@ -1328,7 +1292,7 @@ void main() { editText = (findRenderEditable(tester).text! as TextSpan).text!; expect(editText.substring(editText.length - 1), '\u2022'); - }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.android })); + }, variant: const TargetPlatformVariant({ 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 0ae6295e65d1..9d9147cf6d40 100644 --- a/packages/flutter/test/widgets/editable_text_cursor_test.dart +++ b/packages/flutter/test/widgets/editable_text_cursor_test.dart @@ -166,61 +166,75 @@ void main() { ); }); - testWidgets('Cursor animates', (WidgetTester tester) async { - const Widget widget = MaterialApp( - home: Material( - child: TextField( - maxLines: 3, + testWidgets('Cursor animates on iOS', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material( + child: TextField(), ), ), ); - await tester.pumpWidget(widget); - await tester.tap(find.byType(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); + expect(renderEditable.cursorColor!.opacity, 1.0); + + int walltimeMicrosecond = 0; + double lastVerifiedOpacity = 1.0; + + Future 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.iOS })); + + testWidgets('Cursor does not animate on non-iOS platforms', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Material(child: TextField(maxLines: 3)), + ), + ); - // Trigger initial timer. When focusing the first time, the cursor shows - // for slightly longer than the average on time. + await tester.tap(find.byType(TextField)); await tester.pump(); - 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))); - - 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)); + // 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: 50)); - // Cursor starts coming back. - expect(renderEditable.cursorColor!.alpha, 79); - expect(renderEditable, paints..rrect(color: const Color(0x4f2196f3))); - }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + for (int i = 0; i < 40; i += 1) { + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.hasRunningAnimations, false); + } + }, variant: TargetPlatformVariant(TargetPlatform.values.toSet()..remove(TargetPlatform.iOS))); testWidgets('Cursor does not animate on Android', (WidgetTester tester) async { final Color defaultCursorColor = Color(ThemeData.fallback().colorScheme.primary.value); @@ -956,4 +970,40 @@ void main() { ); EditableText.debugDeterministicCursor = false; }, variant: const TargetPlatformVariant({ 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 3e46034def0b..55acff909ff5 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -3943,42 +3943,6 @@ void main() { 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 buildApp(MockTextSelectionControls controls, WidgetTester tester) { return tester.pumpWidget(MaterialApp(