Skip to content

Commit

Permalink
Disable cursor opacity animation on macOS, make iOS cursor animation …
Browse files Browse the repository at this point in the history
…discrete (#104335)
  • Loading branch information
LongCatIsLooong authored Jun 22, 2022
1 parent 1857532 commit 60f30e5
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 228 deletions.
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/material/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1168,7 +1168,7 @@ class _TextFieldState extends State<TextField> 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);
Expand Down
220 changes: 143 additions & 77 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,6 @@ typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>);
// 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1597,7 +1678,14 @@ class EditableText extends StatefulWidget {
/// State for a [EditableText].
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, 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<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
final GlobalKey _editableKey = GlobalKey();
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
Expand All @@ -1608,8 +1696,6 @@ class EditableTextState extends State<EditableText> 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();
Expand Down Expand Up @@ -1637,10 +1723,6 @@ class EditableTextState extends State<EditableText> 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);
Expand All @@ -1652,7 +1734,7 @@ class EditableTextState extends State<EditableText> 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;
Expand Down Expand Up @@ -1806,10 +1888,6 @@ class EditableTextState extends State<EditableText> 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);
Expand Down Expand Up @@ -1846,7 +1924,7 @@ class EditableTextState extends State<EditableText> 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();
Expand Down Expand Up @@ -1946,8 +2024,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
assert(!_hasInputConnection);
_cursorTimer?.cancel();
_cursorTimer = null;
_cursorBlinkOpacityController?.dispose();
_cursorBlinkOpacityController = null;
_backingCursorBlinkOpacityController?.dispose();
_backingCursorBlinkOpacityController = null;
_selectionOverlay?.dispose();
_selectionOverlay = null;
widget.focusNode.removeListener(_handleFocusChanged);
Expand Down Expand Up @@ -2026,8 +2104,8 @@ class EditableTextState extends State<EditableText> 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();
}
}

Expand Down Expand Up @@ -2548,8 +2626,8 @@ class EditableTextState extends State<EditableText> 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();
}
}

Expand Down Expand Up @@ -2703,14 +2781,14 @@ class EditableTextState extends State<EditableText> 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
Expand All @@ -2725,83 +2803,69 @@ class EditableTextState extends State<EditableText> 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();
}
}

Expand Down Expand Up @@ -3488,8 +3552,10 @@ class EditableTextState extends State<EditableText> 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<TargetPlatform> mobilePlatforms = <TargetPlatform> {
TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia,
TargetPlatform.android, TargetPlatform.fuchsia,
};
final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
&& mobilePlatforms.contains(defaultTargetPlatform);
Expand Down
Loading

0 comments on commit 60f30e5

Please sign in to comment.