Unverified Commit 60f30e5d authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Disable cursor opacity animation on macOS, make iOS cursor animation discrete (#104335)

parent 18575321
...@@ -1168,7 +1168,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1168,7 +1168,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
forcePressEnabled = false; forcePressEnabled = false;
textSelectionControls ??= cupertinoDesktopTextSelectionControls; textSelectionControls ??= cupertinoDesktopTextSelectionControls;
paintCursorAboveText = true; paintCursorAboveText = true;
cursorOpacityAnimates = true; cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor; cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
selectionColor = selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); selectionColor = selectionStyle.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40);
cursorRadius ??= const Radius.circular(2.0); cursorRadius ??= const Radius.circular(2.0);
......
...@@ -52,10 +52,6 @@ typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>); ...@@ -52,10 +52,6 @@ typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>);
// to transparent, is twice this duration. // to transparent, is twice this duration.
const Duration _kCursorBlinkHalfPeriod = Duration(milliseconds: 500); 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 // Number of cursor ticks during which the most recently entered character
// is shown in an obscured text field. // is shown in an obscured text field.
const int _kObscureShowLatestCharCursorTicks = 3; const int _kObscureShowLatestCharCursorTicks = 3;
...@@ -301,6 +297,91 @@ class ToolbarOptions { ...@@ -301,6 +297,91 @@ class ToolbarOptions {
final bool selectAll; 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. /// A basic text input field.
/// ///
/// This widget interacts with the [TextInput] service to let the user edit the /// This widget interacts with the [TextInput] service to let the user edit the
...@@ -1597,7 +1678,14 @@ class EditableText extends StatefulWidget { ...@@ -1597,7 +1678,14 @@ class EditableText extends StatefulWidget {
/// State for a [EditableText]. /// State for a [EditableText].
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate implements TextInputClient, AutofillClient { class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate implements TextInputClient, AutofillClient {
Timer? _cursorTimer; 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 ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
final GlobalKey _editableKey = GlobalKey(); final GlobalKey _editableKey = GlobalKey();
final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier(); final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
...@@ -1608,8 +1696,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1608,8 +1696,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
ScrollController? _internalScrollController; ScrollController? _internalScrollController;
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController()); ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
AnimationController? _cursorBlinkOpacityController;
final LayerLink _toolbarLayerLink = LayerLink(); final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink(); final LayerLink _endHandleLayerLink = LayerLink();
...@@ -1637,10 +1723,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1637,10 +1723,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// - Changing the selection using a physical keyboard. /// - Changing the selection using a physical keyboard.
bool get _shouldCreateInputConnection => kIsWeb || !widget.readOnly; 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 // The time it takes for the floating cursor to snap to the text aligned
// cursor position after the user has finished placing it. // cursor position after the user has finished placing it.
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125); static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
...@@ -1652,7 +1734,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1652,7 +1734,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
bool get wantKeepAlive => widget.focusNode.hasFocus; bool get wantKeepAlive => widget.focusNode.hasFocus;
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value); Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
@override @override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText; bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
...@@ -1806,10 +1888,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1806,10 +1888,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_cursorBlinkOpacityController = AnimationController(
vsync: this,
duration: _fadeDuration,
)..addListener(_onCursorColorTick);
_clipboardStatus?.addListener(_onChangedClipboardStatus); _clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue); widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged);
...@@ -1846,7 +1924,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1846,7 +1924,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (_tickersEnabled != newTickerEnabled) { if (_tickersEnabled != newTickerEnabled) {
_tickersEnabled = newTickerEnabled; _tickersEnabled = newTickerEnabled;
if (_tickersEnabled && _cursorActive) { if (_tickersEnabled && _cursorActive) {
_startCursorTimer(); _startCursorBlink();
} else if (!_tickersEnabled && _cursorTimer != null) { } else if (!_tickersEnabled && _cursorTimer != null) {
// Cannot use _stopCursorTimer because it would reset _cursorActive. // Cannot use _stopCursorTimer because it would reset _cursorActive.
_cursorTimer!.cancel(); _cursorTimer!.cancel();
...@@ -1946,8 +2024,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1946,8 +2024,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
assert(!_hasInputConnection); assert(!_hasInputConnection);
_cursorTimer?.cancel(); _cursorTimer?.cancel();
_cursorTimer = null; _cursorTimer = null;
_cursorBlinkOpacityController?.dispose(); _backingCursorBlinkOpacityController?.dispose();
_cursorBlinkOpacityController = null; _backingCursorBlinkOpacityController = null;
_selectionOverlay?.dispose(); _selectionOverlay?.dispose();
_selectionOverlay = null; _selectionOverlay = null;
widget.focusNode.removeListener(_handleFocusChanged); widget.focusNode.removeListener(_handleFocusChanged);
...@@ -2026,8 +2104,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2026,8 +2104,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
if (_hasInputConnection) { if (_hasInputConnection) {
// To keep the cursor from blinking while typing, we want to restart the // To keep the cursor from blinking while typing, we want to restart the
// cursor timer every time a new character is typed. // cursor timer every time a new character is typed.
_stopCursorTimer(resetCharTicks: false); _stopCursorBlink(resetCharTicks: false);
_startCursorTimer(); _startCursorBlink();
} }
} }
...@@ -2548,8 +2626,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2548,8 +2626,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// To keep the cursor from blinking while it moves, restart the timer here. // To keep the cursor from blinking while it moves, restart the timer here.
if (_cursorTimer != null) { if (_cursorTimer != null) {
_stopCursorTimer(resetCharTicks: false); _stopCursorBlink(resetCharTicks: false);
_startCursorTimer(); _startCursorBlink();
} }
} }
...@@ -2703,14 +2781,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2703,14 +2781,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
void _onCursorColorTick() { void _onCursorColorTick() {
renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController!.value); renderEditable.cursorColor = widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
_cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController!.value > 0; _cursorVisibilityNotifier.value = widget.showCursor && _cursorBlinkOpacityController.value > 0;
} }
/// Whether the blinking cursor is actually visible at this precise moment /// Whether the blinking cursor is actually visible at this precise moment
/// (it's hidden half the time, since it blinks). /// (it's hidden half the time, since it blinks).
@visibleForTesting @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" /// 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 /// state or the "off" state). A complete cursor blink period is twice this
...@@ -2725,83 +2803,69 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2725,83 +2803,69 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
int _obscureShowCharTicksPending = 0; int _obscureShowCharTicksPending = 0;
int? _obscureLatestCharIndex; 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 // Indicates whether the cursor should be blinking right now (but it may
// actually not blink because it's disabled via TickerMode.of(context)). // actually not blink because it's disabled via TickerMode.of(context)).
bool _cursorActive = false; bool _cursorActive = false;
void _startCursorTimer() { void _startCursorBlink() {
assert(_cursorTimer == null); assert(!(_cursorTimer?.isActive ?? false) || !(_backingCursorBlinkOpacityController?.isAnimating ?? false));
_cursorActive = true; _cursorActive = true;
if (!_tickersEnabled) { if (!_tickersEnabled) {
return; return;
} }
_targetCursorVisibility = true; _cursorTimer?.cancel();
_cursorBlinkOpacityController!.value = 1.0; _cursorBlinkOpacityController.value = 1.0;
if (EditableText.debugDeterministicCursor) { if (EditableText.debugDeterministicCursor) {
return; return;
} }
if (widget.cursorOpacityAnimates) { if (widget.cursorOpacityAnimates) {
_cursorTimer = Timer.periodic(_kCursorBlinkWaitForStart, _cursorWaitForStart); _cursorBlinkOpacityController.animateWith(_iosBlinkCursorSimulation).whenComplete(_onCursorTick);
} else { } else {
_cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, _cursorTick); _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { _onCursorTick(); });
} }
} }
void _stopCursorTimer({ bool resetCharTicks = true }) { void _onCursorTick() {
_cursorActive = false; if (_obscureShowCharTicksPending > 0) {
_obscureShowCharTicksPending = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
? _obscureShowCharTicksPending - 1
: 0;
if (_obscureShowCharTicksPending == 0) {
setState(() { });
}
}
if (widget.cursorOpacityAnimates) {
_cursorTimer?.cancel(); _cursorTimer?.cancel();
_cursorTimer = null; // Schedule this as an async task to avoid blocking tester.pumpAndSettle
_targetCursorVisibility = false; // indefinitely.
_cursorBlinkOpacityController!.value = 0.0; _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;
_cursorBlinkOpacityController.value = 0.0;
if (EditableText.debugDeterministicCursor) { if (EditableText.debugDeterministicCursor) {
return; return;
} }
_cursorBlinkOpacityController.value = 0.0;
if (resetCharTicks) { if (resetCharTicks) {
_obscureShowCharTicksPending = 0; _obscureShowCharTicksPending = 0;
} }
if (widget.cursorOpacityAnimates) {
_cursorBlinkOpacityController!.stop();
_cursorBlinkOpacityController!.value = 0.0;
}
} }
void _startOrStopCursorTimerIfNeeded() { void _startOrStopCursorTimerIfNeeded() {
if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) { if (_cursorTimer == null && _hasFocus && _value.selection.isCollapsed) {
_startCursorTimer(); _startCursorBlink();
} else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) { }
_stopCursorTimer(); else if (_cursorActive && (!_hasFocus || !_value.selection.isCollapsed)) {
_stopCursorBlink();
} }
} }
...@@ -3488,8 +3552,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3488,8 +3552,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
String text = _value.text; String text = _value.text;
text = widget.obscuringCharacter * text.length; text = widget.obscuringCharacter * text.length;
// Reveal the latest character in an obscured field only on mobile. // 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> { const Set<TargetPlatform> mobilePlatforms = <TargetPlatform> {
TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.fuchsia, TargetPlatform.android, TargetPlatform.fuchsia,
}; };
final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword final bool breiflyShowPassword = WidgetsBinding.instance.platformDispatcher.brieflyShowPassword
&& mobilePlatforms.contains(defaultTargetPlatform); && mobilePlatforms.contains(defaultTargetPlatform);
......
...@@ -702,40 +702,6 @@ void main() { ...@@ -702,40 +702,6 @@ void main() {
expect(editableText.cursorOffset, const Offset(-2.0 / 3.0, 0)); 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>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('Cursor radius is 2.0', (WidgetTester tester) async { testWidgets('Cursor radius is 2.0', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
const CupertinoApp( const CupertinoApp(
......
...@@ -609,42 +609,6 @@ void main() { ...@@ -609,42 +609,6 @@ void main() {
await checkCursorToggle(); 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>{ TargetPlatform.iOS, TargetPlatform.macOS }));
// Regression test for https://github.com/flutter/flutter/issues/78918. // Regression test for https://github.com/flutter/flutter/issues/78918.
testWidgets('RenderEditable sets correct text editing value', (WidgetTester tester) async { testWidgets('RenderEditable sets correct text editing value', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(text: 'how are you'); final TextEditingController controller = TextEditingController(text: 'how are you');
...@@ -1328,7 +1292,7 @@ void main() { ...@@ -1328,7 +1292,7 @@ void main() {
editText = (findRenderEditable(tester).text! as TextSpan).text!; editText = (findRenderEditable(tester).text! as TextSpan).text!;
expect(editText.substring(editText.length - 1), '\u2022'); expect(editText.substring(editText.length - 1), '\u2022');
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android }));
testWidgets('desktop obscureText control test', (WidgetTester tester) async { testWidgets('desktop obscureText control test', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
......
...@@ -166,61 +166,75 @@ void main() { ...@@ -166,61 +166,75 @@ void main() {
); );
}); });
testWidgets('Cursor animates', (WidgetTester tester) async { testWidgets('Cursor animates on iOS', (WidgetTester tester) async {
const Widget widget = MaterialApp( await tester.pumpWidget(
const MaterialApp(
home: Material( home: Material(
child: TextField( child: TextField(),
maxLines: 3,
), ),
), ),
); );
await tester.pumpWidget(widget);
await tester.tap(find.byType(TextField)); final Finder textFinder = find.byType(TextField);
await tester.tap(textFinder);
await tester.pump(); await tester.pump();
final EditableTextState editableTextState = tester.firstState(find.byType(EditableText)); final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
final RenderEditable renderEditable = editableTextState.renderEditable; final RenderEditable renderEditable = editableTextState.renderEditable;
expect(renderEditable.cursorColor!.alpha, 255); expect(renderEditable.cursorColor!.opacity, 1.0);
// Trigger initial timer. When focusing the first time, the cursor shows int walltimeMicrosecond = 0;
// for slightly longer than the average on time. double lastVerifiedOpacity = 1.0;
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)); Future<void> verifyKeyFrame({ required double opacity, required int at }) async {
// Start to animate the cursor away. const int delta = 1;
expect(renderEditable.cursorColor!.alpha, 255); assert(at - delta > walltimeMicrosecond);
expect(renderEditable, paints..rrect(color: const Color(0xff2196f3))); await tester.pump(Duration(microseconds: at - delta - walltimeMicrosecond));
await tester.pump(const Duration(milliseconds: 100)); // Instead of verifying the opacity at each key frame, this function
expect(renderEditable.cursorColor!.alpha, 110); // verifies the opacity immediately *before* each key frame to avoid
expect(renderEditable, paints..rrect(color: const Color(0x6e2196f3))); // fp precision issues.
expect(
renderEditable.cursorColor!.opacity,
closeTo(lastVerifiedOpacity, 0.01),
reason: 'opacity at ${at-delta} microseconds',
);
await tester.pump(const Duration(milliseconds: 100)); walltimeMicrosecond = at - delta;
expect(renderEditable.cursorColor!.alpha, 16); lastVerifiedOpacity = opacity;
expect(renderEditable, paints..rrect(color: const Color(0x102196f3))); }
await tester.pump(const Duration(milliseconds: 100)); await verifyKeyFrame(opacity: 1.0, at: 500000);
expect(renderEditable.cursorColor!.alpha, 0); await verifyKeyFrame(opacity: 0.75, at: 537500);
// Don't try to draw the cursor. await verifyKeyFrame(opacity: 0.5, at: 575000);
expect(renderEditable, paintsExactlyCountTimes(#drawRRect, 0)); 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>{ 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)),
),
);
// Wait some more while the cursor is gone. It'll trigger the cursor to await tester.tap(find.byType(TextField));
// start animating in again. await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // Wait for the current animation to finish. If the cursor never stops its
expect(renderEditable.cursorColor!.alpha, 0); // blinking animation the test will timeout.
expect(renderEditable, paintsExactlyCountTimes(#drawRRect, 0)); await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 50)); for (int i = 0; i < 40; i += 1) {
// Cursor starts coming back. await tester.pump(const Duration(milliseconds: 100));
expect(renderEditable.cursorColor!.alpha, 79); expect(tester.hasRunningAnimations, false);
expect(renderEditable, paints..rrect(color: const Color(0x4f2196f3))); }
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: TargetPlatformVariant(TargetPlatform.values.toSet()..remove(TargetPlatform.iOS)));
testWidgets('Cursor does not animate on Android', (WidgetTester tester) async { testWidgets('Cursor does not animate on Android', (WidgetTester tester) async {
final Color defaultCursorColor = Color(ThemeData.fallback().colorScheme.primary.value); final Color defaultCursorColor = Color(ThemeData.fallback().colorScheme.primary.value);
...@@ -956,4 +970,40 @@ void main() { ...@@ -956,4 +970,40 @@ void main() {
); );
EditableText.debugDeterministicCursor = false; EditableText.debugDeterministicCursor = false;
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ 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, '•••');
});
} }
...@@ -3943,42 +3943,6 @@ void main() { ...@@ -3943,42 +3943,6 @@ void main() {
expect((findRenderEditable(tester).text! as TextSpan).text, '•••'); 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', () { group('a11y copy/cut/paste', () {
Future<void> buildApp(MockTextSelectionControls controls, WidgetTester tester) { Future<void> buildApp(MockTextSelectionControls controls, WidgetTester tester) {
return tester.pumpWidget(MaterialApp( return tester.pumpWidget(MaterialApp(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment