Skip to content

Commit 60f30e5

Browse files
Disable cursor opacity animation on macOS, make iOS cursor animation discrete (#104335)
1 parent 1857532 commit 60f30e5

File tree

6 files changed

+238
-228
lines changed

6 files changed

+238
-228
lines changed

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

+1-1
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

+143-77
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
@@ -1597,7 +1678,14 @@ class EditableText extends StatefulWidget {
15971678
/// State for a [EditableText].
15981679
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate implements TextInputClient, AutofillClient {
15991680
Timer? _cursorTimer;
1600-
bool _targetCursorVisibility = false;
1681+
AnimationController get _cursorBlinkOpacityController {
1682+
return _backingCursorBlinkOpacityController ??= AnimationController(
1683+
vsync: this,
1684+
)..addListener(_onCursorColorTick);
1685+
}
1686+
AnimationController? _backingCursorBlinkOpacityController;
1687+
late final Simulation _iosBlinkCursorSimulation = _DiscreteKeyFrameSimulation.iOSBlinkingCaret();
1688+
16011689
final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
16021690
final GlobalKey _editableKey = GlobalKey();
16031691
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
@@ -1608,8 +1696,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16081696
ScrollController? _internalScrollController;
16091697
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
16101698

1611-
AnimationController? _cursorBlinkOpacityController;
1612-
16131699
final LayerLink _toolbarLayerLink = LayerLink();
16141700
final LayerLink _startHandleLayerLink = LayerLink();
16151701
final LayerLink _endHandleLayerLink = LayerLink();
@@ -1637,10 +1723,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16371723
/// - Changing the selection using a physical keyboard.
16381724
bool get _shouldCreateInputConnection => kIsWeb || !widget.readOnly;
16391725

1640-
// This value is an eyeball estimation of the time it takes for the iOS cursor
1641-
// to ease in and out.
1642-
static const Duration _fadeDuration = Duration(milliseconds: 250);
1643-
16441726
// The time it takes for the floating cursor to snap to the text aligned
16451727
// cursor position after the user has finished placing it.
16461728
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
@@ -1652,7 +1734,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16521734
@override
16531735
bool get wantKeepAlive => widget.focusNode.hasFocus;
16541736

1655-
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
1737+
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
16561738

16571739
@override
16581740
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
@@ -1806,10 +1888,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
18061888
@override
18071889
void initState() {
18081890
super.initState();
1809-
_cursorBlinkOpacityController = AnimationController(
1810-
vsync: this,
1811-
duration: _fadeDuration,
1812-
)..addListener(_onCursorColorTick);
18131891
_clipboardStatus?.addListener(_onChangedClipboardStatus);
18141892
widget.controller.addListener(_didChangeTextEditingValue);
18151893
widget.focusNode.addListener(_handleFocusChanged);
@@ -1846,7 +1924,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
18461924
if (_tickersEnabled != newTickerEnabled) {
18471925
_tickersEnabled = newTickerEnabled;
18481926
if (_tickersEnabled && _cursorActive) {
1849-
_startCursorTimer();
1927+
_startCursorBlink();
18501928
} else if (!_tickersEnabled && _cursorTimer != null) {
18511929
// Cannot use _stopCursorTimer because it would reset _cursorActive.
18521930
_cursorTimer!.cancel();
@@ -1946,8 +2024,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
19462024
assert(!_hasInputConnection);
19472025
_cursorTimer?.cancel();
19482026
_cursorTimer = null;
1949-
_cursorBlinkOpacityController?.dispose();
1950-
_cursorBlinkOpacityController = null;
2027+
_backingCursorBlinkOpacityController?.dispose();
2028+
_backingCursorBlinkOpacityController = null;
19512029
_selectionOverlay?.dispose();
19522030
_selectionOverlay = null;
19532031
widget.focusNode.removeListener(_handleFocusChanged);
@@ -2026,8 +2104,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
20262104
if (_hasInputConnection) {
20272105
// To keep the cursor from blinking while typing, we want to restart the
20282106
// cursor timer every time a new character is typed.
2029-
_stopCursorTimer(resetCharTicks: false);
2030-
_startCursorTimer();
2107+
_stopCursorBlink(resetCharTicks: false);
2108+
_startCursorBlink();
20312109
}
20322110
}
20332111

@@ -2548,8 +2626,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
25482626

25492627
// To keep the cursor from blinking while it moves, restart the timer here.
25502628
if (_cursorTimer != null) {
2551-
_stopCursorTimer(resetCharTicks: false);
2552-
_startCursorTimer();
2629+
_stopCursorBlink(resetCharTicks: false);
2630+
_startCursorBlink();
25532631
}
25542632
}
25552633

@@ -2703,14 +2781,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
27032781
}
27042782

27052783
void _onCursorColorTick() {
2706-
renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value);
2707-
_cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController!.value > 0;
2784+
renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
2785+
_cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController.value > 0;
27082786
}
27092787

27102788
/// Whether the blinking cursor is actually visible at this precise moment
27112789
/// (it's hidden half the time, since it blinks).
27122790
@visibleForTesting
2713-
bool get cursorCurrentlyVisible => _cursorBlinkOpacityController!.value > 0;
2791+
bool get cursorCurrentlyVisible => _cursorBlinkOpacityController.value > 0;
27142792

27152793
/// The cursor blink interval (the amount of time the cursor is in the "on"
27162794
/// state or the "off" state). A complete cursor blink period is twice this
@@ -2725,83 +2803,69 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
27252803
int _obscureShowCharTicksPending = 0;
27262804
int? _obscureLatestCharIndex;
27272805

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

2764-
void _startCursorTimer() {
2765-
assert(_cursorTimer == null);
2810+
void _startCursorBlink() {
2811+
assert(!(_cursorTimer?.isActive ?? false) || !(_backingCursorBlinkOpacityController?.isAnimating ?? false));
27662812
_cursorActive = true;
27672813
if (!_tickersEnabled) {
27682814
return;
27692815
}
2770-
_targetCursorVisibility = true;
2771-
_cursorBlinkOpacityController!.value = 1.0;
2816+
_cursorTimer?.cancel();
2817+
_cursorBlinkOpacityController.value = 1.0;
27722818
if (EditableText.debugDeterministicCursor) {
27732819
return;
27742820
}
27752821
if (widget.cursorOpacityAnimates) {
2776-
_cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart);
2822+
_cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick);
27772823
} else {
2778-
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
2824+
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
27792825
}
27802826
}
27812827

2782-
void _stopCursorTimer({ bool resetCharTicks = true }) {
2828+
void _onCursorTick() {
2829+
if (_obscureShowCharTicksPending > 0) {
2830+
_obscureShowCharTicksPending = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
2831+
? _obscureShowCharTicksPending - 1
2832+
: 0;
2833+
if (_obscureShowCharTicksPending == 0) {
2834+
setState(() { });
2835+
}
2836+
}
2837+
2838+
if (widget.cursorOpacityAnimates) {
2839+
_cursorTimer?.cancel();
2840+
// Schedule this as an async task to avoid blocking tester.pumpAndSettle
2841+
// indefinitely.
2842+
_cursorTimer = Timer(Duration.zero, () => _cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick));
2843+
} else {
2844+
if (!(_cursorTimer?.isActive ?? false) && _tickersEnabled) {
2845+
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
2846+
}
2847+
_cursorBlinkOpacityController.value = _cursorBlinkOpacityController.value == 0 ? 1 : 0;
2848+
}
2849+
}
2850+
2851+
void _stopCursorBlink({ bool resetCharTicks = true }) {
27832852
_cursorActive = false;
2784-
_cursorTimer?.cancel();
2785-
_cursorTimer = null;
2786-
_targetCursorVisibility = false;
2787-
_cursorBlinkOpacityController!.value = 0.0;
2853+
_cursorBlinkOpacityController.value = 0.0;
27882854
if (EditableText.debugDeterministicCursor) {
27892855
return;
27902856
}
2857+
_cursorBlinkOpacityController.value = 0.0;
27912858
if (resetCharTicks) {
27922859
_obscureShowCharTicksPending = 0;
27932860
}
2794-
if (widget.cursorOpacityAnimates) {
2795-
_cursorBlinkOpacityController!.stop();
2796-
_cursorBlinkOpacityController!.value = 0.0;
2797-
}
27982861
}
27992862

28002863
void _startOrStopCursorTimerIfNeeded() {
28012864
if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) {
2802-
_startCursorTimer();
2803-
} else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
2804-
_stopCursorTimer();
2865+
_startCursorBlink();
2866+
}
2867+
else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
2868+
_stopCursorBlink();
28052869
}
28062870
}
28072871

@@ -3488,8 +3552,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
34883552
String text = _value.text;
34893553
text = widget.obscuringCharacter * text.length;
34903554
// Reveal the latest character in an obscured field only on mobile.
3555+
// Newer verions of iOS (iOS 15+) no longer reveal the most recently
3556+
// entered character.
34913557
const Set<TargetPlatform> mobilePlatforms = <TargetPlatform> {
3492-
TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia,
3558+
TargetPlatform.android, TargetPlatform.fuchsia,
34933559
};
34943560
final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
34953561
&& mobilePlatforms.contains(defaultTargetPlatform);

0 commit comments

Comments
 (0)