Unverified Commit c58dca2a authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Reland "Disable cursor opacity animation on macOS, make iOS cursor animation...

Reland  "Disable cursor opacity animation on macOS, make iOS cursor animation discrete (#104335)" (#106893)
parent 1704d4f5
...@@ -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
...@@ -1606,7 +1687,14 @@ class EditableText extends StatefulWidget { ...@@ -1606,7 +1687,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, TextInputClient implements AutofillClient { class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin<EditableText>, WidgetsBindingObserver, TickerProviderStateMixin<EditableText>, TextSelectionDelegate, TextInputClient implements 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();
...@@ -1617,8 +1705,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1617,8 +1705,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();
...@@ -1646,10 +1732,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1646,10 +1732,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);
...@@ -1661,7 +1743,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1661,7 +1743,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;
...@@ -1815,10 +1897,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1815,10 +1897,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);
...@@ -1855,7 +1933,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1855,7 +1933,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();
...@@ -1955,8 +2033,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1955,8 +2033,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);
...@@ -2035,8 +2113,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2035,8 +2113,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();
} }
} }
...@@ -2557,8 +2635,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2557,8 +2635,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();
} }
} }
...@@ -2712,14 +2790,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2712,14 +2790,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
...@@ -2734,83 +2812,67 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2734,83 +2812,67 @@ 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() {
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; _cursorActive = false;
_cursorBlinkOpacityController.value = 0.0;
_cursorTimer?.cancel(); _cursorTimer?.cancel();
_cursorTimer = null; _cursorTimer = null;
_targetCursorVisibility = false;
_cursorBlinkOpacityController!.value = 0.0;
if (EditableText.debugDeterministicCursor) {
return;
}
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();
} }
} }
...@@ -3497,8 +3559,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3497,8 +3559,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(
...@@ -2998,7 +2964,7 @@ void main() { ...@@ -2998,7 +2964,7 @@ void main() {
// Selection should stay the same since it is set on tap up for mobile platforms. // Selection should stay the same since it is set on tap up for mobile platforms.
await touchGesture.down(gPos); await touchGesture.down(gPos);
await tester.pumpAndSettle(); await tester.pump();
expect(controller.selection.baseOffset, isTargetPlatformApple ? 4 : 5); expect(controller.selection.baseOffset, isTargetPlatformApple ? 4 : 5);
expect(controller.selection.extentOffset, isTargetPlatformApple ? 4 : 5); expect(controller.selection.extentOffset, isTargetPlatformApple ? 4 : 5);
......
...@@ -618,42 +618,6 @@ void main() { ...@@ -618,42 +618,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');
...@@ -1340,7 +1304,7 @@ void main() { ...@@ -1340,7 +1304,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(
...@@ -1994,7 +1958,7 @@ void main() { ...@@ -1994,7 +1958,7 @@ void main() {
// Selection should stay the same since it is set on tap up for mobile platforms. // Selection should stay the same since it is set on tap up for mobile platforms.
await touchGesture.down(gPos); await touchGesture.down(gPos);
await tester.pumpAndSettle(); await tester.pump();
expect(controller.selection.baseOffset, isTargetPlatformApple ? 4 : 5); expect(controller.selection.baseOffset, isTargetPlatformApple ? 4 : 5);
expect(controller.selection.extentOffset, isTargetPlatformApple ? 4 : 5); expect(controller.selection.extentOffset, isTargetPlatformApple ? 4 : 5);
......
...@@ -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(
home: Material( const MaterialApp(
child: TextField( home: Material(
maxLines: 3, child: TextField(),
), ),
), ),
); );
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);
int walltimeMicrosecond = 0;
double lastVerifiedOpacity = 1.0;
Future<void> verifyKeyFrame({ required double opacity, required int at }) async {
const int delta = 1;
assert(at - delta > walltimeMicrosecond);
await tester.pump(Duration(microseconds: at - delta - walltimeMicrosecond));
// Instead of verifying the opacity at each key frame, this function
// verifies the opacity immediately *before* each key frame to avoid
// fp precision issues.
expect(
renderEditable.cursorColor!.opacity,
closeTo(lastVerifiedOpacity, 0.01),
reason: 'opacity at ${at-delta} microseconds',
);
walltimeMicrosecond = at - delta;
lastVerifiedOpacity = opacity;
}
await verifyKeyFrame(opacity: 1.0, at: 500000);
await verifyKeyFrame(opacity: 0.75, at: 537500);
await verifyKeyFrame(opacity: 0.5, at: 575000);
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)),
),
);
// Trigger initial timer. When focusing the first time, the cursor shows await tester.tap(find.byType(TextField));
// for slightly longer than the average on time.
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 200)); // Wait for the current animation to finish. If the cursor never stops its
// Start timing standard cursor show period. // blinking animation the test will timeout.
expect(renderEditable.cursorColor!.alpha, 255); await tester.pumpAndSettle();
expect(renderEditable, paints..rrect(color: const Color(0xff2196f3)));
await tester.pump(const Duration(milliseconds: 500));
// Start to animate the cursor away.
expect(renderEditable.cursorColor!.alpha, 255);
expect(renderEditable, paints..rrect(color: const Color(0xff2196f3)));
await tester.pump(const Duration(milliseconds: 100));
expect(renderEditable.cursorColor!.alpha, 110);
expect(renderEditable, paints..rrect(color: const Color(0x6e2196f3)));
await tester.pump(const Duration(milliseconds: 100));
expect(renderEditable.cursorColor!.alpha, 16);
expect(renderEditable, paints..rrect(color: const Color(0x102196f3)));
await tester.pump(const Duration(milliseconds: 100));
expect(renderEditable.cursorColor!.alpha, 0);
// Don't try to draw the cursor.
expect(renderEditable, paintsExactlyCountTimes(#drawRRect, 0));
// Wait some more while the cursor is gone. It'll trigger the cursor to
// start animating in again.
await tester.pump(const Duration(milliseconds: 300));
expect(renderEditable.cursorColor!.alpha, 0);
expect(renderEditable, paintsExactlyCountTimes(#drawRRect, 0));
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.all(excluding: <TargetPlatform>{ 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);
...@@ -444,6 +458,37 @@ void main() { ...@@ -444,6 +458,37 @@ void main() {
expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0)); expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0));
}); });
testWidgets('Cursor does not show when not focused', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/106512 .
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(focusNode: focusNode, autofocus: true),
),
),
);
assert(focusNode.hasFocus);
final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
final RenderEditable renderEditable = editableTextState.renderEditable;
focusNode.unfocus();
await tester.pump();
for (int i = 0; i < 10; i += 10) {
// Make sure it does not paint for a period of time.
expect(renderEditable, paintsExactlyCountTimes(#drawRect, 0));
expect(tester.hasRunningAnimations, isFalse);
await tester.pump(const Duration(milliseconds: 29));
}
// Refocus and it should paint the caret.
focusNode.requestFocus();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(renderEditable, isNot(paintsExactlyCountTimes(#drawRect, 0)));
});
testWidgets('Cursor radius is 2.0', (WidgetTester tester) async { testWidgets('Cursor radius is 2.0', (WidgetTester tester) async {
const Widget widget = MaterialApp( const Widget widget = MaterialApp(
home: Material( home: Material(
...@@ -956,4 +1001,40 @@ void main() { ...@@ -956,4 +1001,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