Skip to content

Commit c58dca2

Browse files
Reland "Disable cursor opacity animation on macOS, make iOS cursor animation discrete (#104335)" (#106893)
1 parent 1704d4f commit c58dca2

6 files changed

Lines changed: 270 additions & 231 deletions

File tree

packages/flutter/lib/src/material/text_field.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1168,7 +1168,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
11681168
forcePressEnabled = false;
11691169
textSelectionControls ??= cupertinoDesktopTextSelectionControls;
11701170
paintCursorAboveText = true;
1171-
cursorOpacityAnimates = true;
1171+
cursorOpacityAnimates = false;
11721172
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
11731173
selectionColor = selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
11741174
cursorRadius ??= const Radius.circular(2.0);

packages/flutter/lib/src/widgets/editable_text.dart

Lines changed: 142 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,6 @@ typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>);
5252
// to transparent, is twice this duration.
5353
const Duration _kCursorBlinkHalfPeriod = Duration(milliseconds: 500);
5454

55-
// The time the cursor is static in opacity before animating to become
56-
// transparent.
57-
const Duration _kCursorBlinkWaitForStart = Duration(milliseconds: 150);
58-
5955
// Number of cursor ticks during which the most recently entered character
6056
// is shown in an obscured text field.
6157
const int _kObscureShowLatestCharCursorTicks = 3;
@@ -301,6 +297,91 @@ class ToolbarOptions {
301297
final bool selectAll;
302298
}
303299

300+
// A time-value pair that represents a key frame in an animation.
301+
class _KeyFrame {
302+
const _KeyFrame(this.time, this.value);
303+
// Values extracted from iOS 15.4 UIKit.
304+
static const List<_KeyFrame> iOSBlinkingCaretKeyFrames = <_KeyFrame>[
305+
_KeyFrame(0, 1), // 0
306+
_KeyFrame(0.5, 1), // 1
307+
_KeyFrame(0.5375, 0.75), // 2
308+
_KeyFrame(0.575, 0.5), // 3
309+
_KeyFrame(0.6125, 0.25), // 4
310+
_KeyFrame(0.65, 0), // 5
311+
_KeyFrame(0.85, 0), // 6
312+
_KeyFrame(0.8875, 0.25), // 7
313+
_KeyFrame(0.925, 0.5), // 8
314+
_KeyFrame(0.9625, 0.75), // 9
315+
_KeyFrame(1, 1), // 10
316+
];
317+
318+
// The timing, in seconds, of the specified animation `value`.
319+
final double time;
320+
final double value;
321+
}
322+
323+
class _DiscreteKeyFrameSimulation extends Simulation {
324+
_DiscreteKeyFrameSimulation.iOSBlinkingCaret() : this._(_KeyFrame.iOSBlinkingCaretKeyFrames, 1);
325+
_DiscreteKeyFrameSimulation._(this._keyFrames, this.maxDuration)
326+
: assert(_keyFrames.isNotEmpty),
327+
assert(_keyFrames.last.time <= maxDuration),
328+
assert(() {
329+
for (int i = 0; i < _keyFrames.length -1; i += 1) {
330+
if (_keyFrames[i].time > _keyFrames[i + 1].time) {
331+
return false;
332+
}
333+
}
334+
return true;
335+
}(), 'The key frame sequence must be sorted by time.');
336+
337+
final double maxDuration;
338+
339+
final List<_KeyFrame> _keyFrames;
340+
341+
@override
342+
double dx(double time) => 0;
343+
344+
@override
345+
bool isDone(double time) => time >= maxDuration;
346+
347+
// The index of the KeyFrame corresponds to the most recent input `time`.
348+
int _lastKeyFrameIndex = 0;
349+
350+
@override
351+
double x(double time) {
352+
final int length = _keyFrames.length;
353+
354+
// Perform a linear search in the sorted key frame list, starting from the
355+
// last key frame found, since the input `time` usually monotonically
356+
// increases by a small amount.
357+
int searchIndex;
358+
final int endIndex;
359+
if (_keyFrames[_lastKeyFrameIndex].time > time) {
360+
// The simulation may have restarted. Search within the index range
361+
// [0, _lastKeyFrameIndex).
362+
searchIndex = 0;
363+
endIndex = _lastKeyFrameIndex;
364+
} else {
365+
searchIndex = _lastKeyFrameIndex;
366+
endIndex = length;
367+
}
368+
369+
// Find the target key frame. Don't have to check (endIndex - 1): if
370+
// (endIndex - 2) doesn't work we'll have to pick (endIndex - 1) anyways.
371+
while (searchIndex < endIndex - 1) {
372+
assert(_keyFrames[searchIndex].time <= time);
373+
final _KeyFrame next = _keyFrames[searchIndex + 1];
374+
if (time < next.time) {
375+
break;
376+
}
377+
searchIndex += 1;
378+
}
379+
380+
_lastKeyFrameIndex = searchIndex;
381+
return _keyFrames[_lastKeyFrameIndex].value;
382+
}
383+
}
384+
304385
/// A basic text input field.
305386
///
306387
/// This widget interacts with the [TextInput] service to let the user edit the
@@ -1606,7 +1687,14 @@ class EditableText extends StatefulWidget {
16061687
/// State for a [EditableText].
16071688
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate, TextInputClient implements AutofillClient {
16081689
Timer? _cursorTimer;
1609-
bool _targetCursorVisibility = false;
1690+
AnimationController get _cursorBlinkOpacityController {
1691+
return _backingCursorBlinkOpacityController ??= AnimationController(
1692+
vsync: this,
1693+
)..addListener(_onCursorColorTick);
1694+
}
1695+
AnimationController? _backingCursorBlinkOpacityController;
1696+
late final Simulation _iosBlinkCursorSimulation = _DiscreteKeyFrameSimulation.iOSBlinkingCaret();
1697+
16101698
final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
16111699
final GlobalKey _editableKey = GlobalKey();
16121700
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
@@ -1617,8 +1705,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16171705
ScrollController? _internalScrollController;
16181706
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
16191707

1620-
AnimationController? _cursorBlinkOpacityController;
1621-
16221708
final LayerLink _toolbarLayerLink = LayerLink();
16231709
final LayerLink _startHandleLayerLink = LayerLink();
16241710
final LayerLink _endHandleLayerLink = LayerLink();
@@ -1646,10 +1732,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16461732
/// - Changing the selection using a physical keyboard.
16471733
bool get _shouldCreateInputConnection => kIsWeb || !widget.readOnly;
16481734

1649-
// This value is an eyeball estimation of the time it takes for the iOS cursor
1650-
// to ease in and out.
1651-
static const Duration _fadeDuration = Duration(milliseconds: 250);
1652-
16531735
// The time it takes for the floating cursor to snap to the text aligned
16541736
// cursor position after the user has finished placing it.
16551737
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
@@ -1661,7 +1743,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16611743
@override
16621744
bool get wantKeepAlive => widget.focusNode.hasFocus;
16631745

1664-
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
1746+
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
16651747

16661748
@override
16671749
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
@@ -1815,10 +1897,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
18151897
@override
18161898
void initState() {
18171899
super.initState();
1818-
_cursorBlinkOpacityController = AnimationController(
1819-
vsync: this,
1820-
duration: _fadeDuration,
1821-
)..addListener(_onCursorColorTick);
18221900
_clipboardStatus?.addListener(_onChangedClipboardStatus);
18231901
widget.controller.addListener(_didChangeTextEditingValue);
18241902
widget.focusNode.addListener(_handleFocusChanged);
@@ -1855,7 +1933,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
18551933
if (_tickersEnabled != newTickerEnabled) {
18561934
_tickersEnabled = newTickerEnabled;
18571935
if (_tickersEnabled && _cursorActive) {
1858-
_startCursorTimer();
1936+
_startCursorBlink();
18591937
} else if (!_tickersEnabled && _cursorTimer != null) {
18601938
// Cannot use _stopCursorTimer because it would reset _cursorActive.
18611939
_cursorTimer!.cancel();
@@ -1955,8 +2033,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
19552033
assert(!_hasInputConnection);
19562034
_cursorTimer?.cancel();
19572035
_cursorTimer = null;
1958-
_cursorBlinkOpacityController?.dispose();
1959-
_cursorBlinkOpacityController = null;
2036+
_backingCursorBlinkOpacityController?.dispose();
2037+
_backingCursorBlinkOpacityController = null;
19602038
_selectionOverlay?.dispose();
19612039
_selectionOverlay = null;
19622040
widget.focusNode.removeListener(_handleFocusChanged);
@@ -2035,8 +2113,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
20352113
if (_hasInputConnection) {
20362114
// To keep the cursor from blinking while typing, we want to restart the
20372115
// cursor timer every time a new character is typed.
2038-
_stopCursorTimer(resetCharTicks: false);
2039-
_startCursorTimer();
2116+
_stopCursorBlink(resetCharTicks: false);
2117+
_startCursorBlink();
20402118
}
20412119
}
20422120

@@ -2557,8 +2635,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
25572635

25582636
// To keep the cursor from blinking while it moves, restart the timer here.
25592637
if (_cursorTimer != null) {
2560-
_stopCursorTimer(resetCharTicks: false);
2561-
_startCursorTimer();
2638+
_stopCursorBlink(resetCharTicks: false);
2639+
_startCursorBlink();
25622640
}
25632641
}
25642642

@@ -2712,14 +2790,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
27122790
}
27132791

27142792
void _onCursorColorTick() {
2715-
renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
2716-
_cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController!.value > 0;
2793+
renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
2794+
_cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController.value > 0;
27172795
}
27182796

27192797
/// Whether the blinking cursor is actually visible at this precise moment
27202798
/// (it's hidden half the time, since it blinks).
27212799
@visibleForTesting
2722-
bool get cursorCurrentlyVisible => _cursorBlinkOpacityController!.value > 0;
2800+
bool get cursorCurrentlyVisible => _cursorBlinkOpacityController.value > 0;
27232801

27242802
/// The cursor blink interval (the amount of time the cursor is in the "on"
27252803
/// state or the "off" state). A complete cursor blink period is twice this
@@ -2734,83 +2812,67 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
27342812
int _obscureShowCharTicksPending = 0;
27352813
int? _obscureLatestCharIndex;
27362814

2737-
void _cursorTick(Timer timer) {
2738-
_targetCursorVisibility = !_targetCursorVisibility;
2739-
final double targetOpacity = _targetCursorVisibility ? 1.0 : 0.0;
2740-
if (widget.cursorOpacityAnimates) {
2741-
// If we want to show the cursor, we will animate the opacity to the value
2742-
// of 1.0, and likewise if we want to make it disappear, to 0.0. An easing
2743-
// curve is used for the animation to mimic the aesthetics of the native
2744-
// iOS cursor.
2745-
//
2746-
// These values and curves have been obtained through eyeballing, so are
2747-
// likely not exactly the same as the values for native iOS.
2748-
_cursorBlinkOpacityController!.animateTo(targetOpacity, curve: Curves.easeOut);
2749-
} else {
2750-
_cursorBlinkOpacityController!.value = targetOpacity;
2751-
}
2752-
2753-
if (_obscureShowCharTicksPending > 0) {
2754-
setState(() {
2755-
_obscureShowCharTicksPending = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
2756-
? _obscureShowCharTicksPending - 1
2757-
: 0;
2758-
});
2759-
}
2760-
}
2761-
2762-
void _cursorWaitForStart(Timer timer) {
2763-
assert(_kCursorBlinkHalfPeriod > _fadeDuration);
2764-
assert(!EditableText.debugDeterministicCursor);
2765-
_cursorTimer?.cancel();
2766-
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
2767-
}
2768-
27692815
// Indicates whether the cursor should be blinking right now (but it may
27702816
// actually not blink because it's disabled via TickerMode.of(context)).
27712817
bool _cursorActive = false;
27722818

2773-
void _startCursorTimer() {
2774-
assert(_cursorTimer == null);
2819+
void _startCursorBlink() {
2820+
assert(!(_cursorTimer?.isActive ?? false) || !(_backingCursorBlinkOpacityController?.isAnimating ?? false));
27752821
_cursorActive = true;
27762822
if (!_tickersEnabled) {
27772823
return;
27782824
}
2779-
_targetCursorVisibility = true;
2780-
_cursorBlinkOpacityController!.value = 1.0;
2825+
_cursorTimer?.cancel();
2826+
_cursorBlinkOpacityController.value = 1.0;
27812827
if (EditableText.debugDeterministicCursor) {
27822828
return;
27832829
}
27842830
if (widget.cursorOpacityAnimates) {
2785-
_cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart);
2831+
_cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick);
27862832
} else {
2787-
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
2833+
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
27882834
}
27892835
}
27902836

2791-
void _stopCursorTimer({ bool resetCharTicks = true }) {
2837+
void _onCursorTick() {
2838+
if (_obscureShowCharTicksPending > 0) {
2839+
_obscureShowCharTicksPending = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
2840+
? _obscureShowCharTicksPending - 1
2841+
: 0;
2842+
if (_obscureShowCharTicksPending == 0) {
2843+
setState(() { });
2844+
}
2845+
}
2846+
2847+
if (widget.cursorOpacityAnimates) {
2848+
_cursorTimer?.cancel();
2849+
// Schedule this as an async task to avoid blocking tester.pumpAndSettle
2850+
// indefinitely.
2851+
_cursorTimer = Timer(Duration.zero, () => _cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick));
2852+
} else {
2853+
if (!(_cursorTimer?.isActive ?? false) && _tickersEnabled) {
2854+
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
2855+
}
2856+
_cursorBlinkOpacityController.value = _cursorBlinkOpacityController.value == 0 ? 1 : 0;
2857+
}
2858+
}
2859+
2860+
void _stopCursorBlink({ bool resetCharTicks = true }) {
27922861
_cursorActive = false;
2862+
_cursorBlinkOpacityController.value = 0.0;
27932863
_cursorTimer?.cancel();
27942864
_cursorTimer = null;
2795-
_targetCursorVisibility = false;
2796-
_cursorBlinkOpacityController!.value = 0.0;
2797-
if (EditableText.debugDeterministicCursor) {
2798-
return;
2799-
}
28002865
if (resetCharTicks) {
28012866
_obscureShowCharTicksPending = 0;
28022867
}
2803-
if (widget.cursorOpacityAnimates) {
2804-
_cursorBlinkOpacityController!.stop();
2805-
_cursorBlinkOpacityController!.value = 0.0;
2806-
}
28072868
}
28082869

28092870
void _startOrStopCursorTimerIfNeeded() {
28102871
if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) {
2811-
_startCursorTimer();
2812-
} else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
2813-
_stopCursorTimer();
2872+
_startCursorBlink();
2873+
}
2874+
else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
2875+
_stopCursorBlink();
28142876
}
28152877
}
28162878

@@ -3497,8 +3559,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
34973559
String text = _value.text;
34983560
text = widget.obscuringCharacter * text.length;
34993561
// Reveal the latest character in an obscured field only on mobile.
3562+
// Newer verions of iOS (iOS 15+) no longer reveal the most recently
3563+
// entered character.
35003564
const Set<TargetPlatform> mobilePlatforms = <TargetPlatform> {
3501-
TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia,
3565+
TargetPlatform.android, TargetPlatform.fuchsia,
35023566
};
35033567
final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
35043568
&& mobilePlatforms.contains(defaultTargetPlatform);

0 commit comments

Comments
 (0)