Skip to content

Commit 8124191

Browse files
Revert "Disable cursor opacity animation on macOS, make iOS cursor animation discrete (#104335)" (#106762)
1 parent 6fbd6ea commit 8124191

File tree

6 files changed

+228
-238
lines changed

6 files changed

+228
-238
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 = false;
1171+
cursorOpacityAnimates = true;
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

+77-143
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ 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+
5559
// Number of cursor ticks during which the most recently entered character
5660
// is shown in an obscured text field.
5761
const int _kObscureShowLatestCharCursorTicks = 3;
@@ -297,91 +301,6 @@ class ToolbarOptions {
297301
final bool selectAll;
298302
}
299303

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-
385304
/// A basic text input field.
386305
///
387306
/// This widget interacts with the [TextInput] service to let the user edit the
@@ -1678,14 +1597,7 @@ class EditableText extends StatefulWidget {
16781597
/// State for a [EditableText].
16791598
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate, TextInputClient implements AutofillClient {
16801599
Timer? _cursorTimer;
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-
1600+
bool _targetCursorVisibility = false;
16891601
final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
16901602
final GlobalKey _editableKey = GlobalKey();
16911603
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
@@ -1696,6 +1608,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
16961608
ScrollController? _internalScrollController;
16971609
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
16981610

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

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+
17261644
// The time it takes for the floating cursor to snap to the text aligned
17271645
// cursor position after the user has finished placing it.
17281646
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
@@ -1734,7 +1652,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
17341652
@override
17351653
bool get wantKeepAlive => widget.focusNode.hasFocus;
17361654

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

17391657
@override
17401658
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
@@ -1888,6 +1806,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
18881806
@override
18891807
void initState() {
18901808
super.initState();
1809+
_cursorBlinkOpacityController = AnimationController(
1810+
vsync: this,
1811+
duration: _fadeDuration,
1812+
)..addListener(_onCursorColorTick);
18911813
_clipboardStatus?.addListener(_onChangedClipboardStatus);
18921814
widget.controller.addListener(_didChangeTextEditingValue);
18931815
widget.focusNode.addListener(_handleFocusChanged);
@@ -1924,7 +1846,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
19241846
if (_tickersEnabled != newTickerEnabled) {
19251847
_tickersEnabled = newTickerEnabled;
19261848
if (_tickersEnabled && _cursorActive) {
1927-
_startCursorBlink();
1849+
_startCursorTimer();
19281850
} else if (!_tickersEnabled && _cursorTimer != null) {
19291851
// Cannot use _stopCursorTimer because it would reset _cursorActive.
19301852
_cursorTimer!.cancel();
@@ -2024,8 +1946,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
20241946
assert(!_hasInputConnection);
20251947
_cursorTimer?.cancel();
20261948
_cursorTimer = null;
2027-
_backingCursorBlinkOpacityController?.dispose();
2028-
_backingCursorBlinkOpacityController = null;
1949+
_cursorBlinkOpacityController?.dispose();
1950+
_cursorBlinkOpacityController = null;
20291951
_selectionOverlay?.dispose();
20301952
_selectionOverlay = null;
20311953
widget.focusNode.removeListener(_handleFocusChanged);
@@ -2104,8 +2026,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
21042026
if (_hasInputConnection) {
21052027
// To keep the cursor from blinking while typing, we want to restart the
21062028
// cursor timer every time a new character is typed.
2107-
_stopCursorBlink(resetCharTicks: false);
2108-
_startCursorBlink();
2029+
_stopCursorTimer(resetCharTicks: false);
2030+
_startCursorTimer();
21092031
}
21102032
}
21112033

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

26272549
// To keep the cursor from blinking while it moves, restart the timer here.
26282550
if (_cursorTimer != null) {
2629-
_stopCursorBlink(resetCharTicks: false);
2630-
_startCursorBlink();
2551+
_stopCursorTimer(resetCharTicks: false);
2552+
_startCursorTimer();
26312553
}
26322554
}
26332555

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

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

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

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

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+
28062760
// Indicates whether the cursor should be blinking right now (but it may
28072761
// actually not blink because it's disabled via TickerMode.of(context)).
28082762
bool _cursorActive = false;
28092763

2810-
void _startCursorBlink() {
2811-
assert(!(_cursorTimer?.isActive ?? false) || !(_backingCursorBlinkOpacityController?.isAnimating ?? false));
2764+
void _startCursorTimer() {
2765+
assert(_cursorTimer == null);
28122766
_cursorActive = true;
28132767
if (!_tickersEnabled) {
28142768
return;
28152769
}
2816-
_cursorTimer?.cancel();
2817-
_cursorBlinkOpacityController.value = 1.0;
2770+
_targetCursorVisibility = true;
2771+
_cursorBlinkOpacityController!.value = 1.0;
28182772
if (EditableText.debugDeterministicCursor) {
28192773
return;
28202774
}
28212775
if (widget.cursorOpacityAnimates) {
2822-
_cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick);
2776+
_cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart);
28232777
} else {
2824-
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
2778+
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick);
28252779
}
28262780
}
28272781

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 }) {
2782+
void _stopCursorTimer({ bool resetCharTicks = true }) {
28522783
_cursorActive = false;
2853-
_cursorBlinkOpacityController.value = 0.0;
2784+
_cursorTimer?.cancel();
2785+
_cursorTimer = null;
2786+
_targetCursorVisibility = false;
2787+
_cursorBlinkOpacityController!.value = 0.0;
28542788
if (EditableText.debugDeterministicCursor) {
28552789
return;
28562790
}
2857-
_cursorBlinkOpacityController.value = 0.0;
28582791
if (resetCharTicks) {
28592792
_obscureShowCharTicksPending = 0;
28602793
}
2794+
if (widget.cursorOpacityAnimates) {
2795+
_cursorBlinkOpacityController!.stop();
2796+
_cursorBlinkOpacityController!.value = 0.0;
2797+
}
28612798
}
28622799

28632800
void _startOrStopCursorTimerIfNeeded() {
28642801
if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) {
2865-
_startCursorBlink();
2866-
}
2867-
else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
2868-
_stopCursorBlink();
2802+
_startCursorTimer();
2803+
} else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
2804+
_stopCursorTimer();
28692805
}
28702806
}
28712807

@@ -3552,10 +3488,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
35523488
String text = _value.text;
35533489
text = widget.obscuringCharacter * text.length;
35543490
// 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.
35573491
const Set<TargetPlatform> mobilePlatforms = <TargetPlatform> {
3558-
TargetPlatform.android, TargetPlatform.fuchsia,
3492+
TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia,
35593493
};
35603494
final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
35613495
&& mobilePlatforms.contains(defaultTargetPlatform);

0 commit comments

Comments
 (0)