diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 67ac45f5..29aeed11 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -5,8 +5,7 @@ import 'package:flutter/widgets.dart'; import 'box.dart'; -const Duration _FADE_DURATION = Duration(milliseconds: 250); - +/// Style properties of editing cursor. class CursorStyle { const CursorStyle({ required this.color, @@ -19,13 +18,52 @@ class CursorStyle { this.paintAboveText = false, }); + /// The color to use when painting the cursor. final Color color; + + /// The color to use when painting the background cursor aligned with the text + /// while rendering the floating cursor. final Color backgroundColor; + + /// How thick the cursor will be. + /// + /// The cursor will draw under the text. The cursor width will extend + /// to the right of the boundary between characters for left-to-right text + /// and to the left for right-to-left text. This corresponds to extending + /// downstream relative to the selected position. Negative values may be used + /// to reverse this behavior. final double width; + + /// How tall the cursor will be. + /// + /// By default, the cursor height is set to the preferred line height of the + /// text. final double? height; + + /// How rounded the corners of the cursor should be. + /// + /// By default, the cursor has no radius. final Radius? radius; + + /// The offset that is used, in pixels, when painting the cursor on screen. + /// + /// By default, the cursor position should be set to an offset of + /// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android + /// platforms. The origin from where the offset is applied to is the arbitrary + /// location where the cursor ends up being rendered from by default. final Offset? offset; + + /// Whether the cursor will animate from fully transparent to fully opaque + /// during each cursor blink. + /// + /// By default, the cursor opacity will animate on iOS platforms and will not + /// animate on Android platforms. final bool opacityAnimates; + + /// If the cursor should be painted on top of the text or underneath it. + /// + /// By default, the cursor should be painted on top for iOS platforms and + /// underneath for Android platforms. final bool paintAboveText; @override @@ -54,6 +92,10 @@ class CursorStyle { paintAboveText.hashCode; } +/// Controls the cursor of an editable widget. +/// +/// This class is a [ChangeNotifier] and allows to listen for updates on the +/// cursor [style]. class CursorCont extends ChangeNotifier { CursorCont({ required this.show, @@ -62,21 +104,35 @@ class CursorCont extends ChangeNotifier { }) : _style = style, blink = ValueNotifier(false), color = ValueNotifier(style.color) { - _blinkOpacityCont = - AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); - _blinkOpacityCont.addListener(_onColorTick); + _blinkOpacityController = + AnimationController(vsync: tickerProvider, duration: _fadeDuration); + _blinkOpacityController.addListener(_onColorTick); } + // The time it takes for the cursor to fade from fully opaque to fully + // transparent and vice versa. A full cursor blink, from transparent to opaque + // to transparent, is twice this duration. + static const Duration _blinkHalfPeriod = Duration(milliseconds: 500); + + // The time the cursor is static in opacity before animating to become + // transparent. + static const Duration _blinkWaitForStart = Duration(milliseconds: 150); + + // 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); + final ValueNotifier show; - final ValueNotifier blink; final ValueNotifier color; - late AnimationController _blinkOpacityCont; + final ValueNotifier blink; + + late final AnimationController _blinkOpacityController; + Timer? _cursorTimer; bool _targetCursorVisibility = false; - CursorStyle _style; + CursorStyle _style; CursorStyle get style => _style; - set style(CursorStyle value) { if (_style == value) return; _style = value; @@ -85,9 +141,9 @@ class CursorCont extends ChangeNotifier { @override void dispose() { - _blinkOpacityCont.removeListener(_onColorTick); + _blinkOpacityController.removeListener(_onColorTick); stopCursorTimer(); - _blinkOpacityCont.dispose(); + _blinkOpacityController.dispose(); show.dispose(); blink.dispose(); color.dispose(); @@ -99,28 +155,32 @@ class CursorCont extends ChangeNotifier { _targetCursorVisibility = !_targetCursorVisibility; final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; if (style.opacityAnimates) { - _blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut); + // 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. + _blinkOpacityController.animateTo(targetOpacity, curve: Curves.easeOut); } else { - _blinkOpacityCont.value = targetOpacity; + _blinkOpacityController.value = targetOpacity; } } - void _cursorWaitForStart(Timer timer) { + void _waitForStart(Timer timer) { _cursorTimer?.cancel(); - _cursorTimer = - Timer.periodic(const Duration(milliseconds: 500), _cursorTick); + _cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick); } void startCursorTimer() { _targetCursorVisibility = true; - _blinkOpacityCont.value = 1.0; + _blinkOpacityController.value = 1.0; if (style.opacityAnimates) { - _cursorTimer = Timer.periodic( - const Duration(milliseconds: 150), _cursorWaitForStart); + _cursorTimer = Timer.periodic(_blinkWaitForStart, _waitForStart); } else { - _cursorTimer = - Timer.periodic(const Duration(milliseconds: 500), _cursorTick); + _cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick); } } @@ -128,10 +188,10 @@ class CursorCont extends ChangeNotifier { _cursorTimer?.cancel(); _cursorTimer = null; _targetCursorVisibility = false; - _blinkOpacityCont.value = 0.0; + _blinkOpacityController.value = 0.0; if (style.opacityAnimates) { - _blinkOpacityCont + _blinkOpacityController ..stop() ..value = 0.0; } @@ -149,32 +209,42 @@ class CursorCont extends ChangeNotifier { } void _onColorTick() { - color.value = _style.color.withOpacity(_blinkOpacityCont.value); - blink.value = show.value && _blinkOpacityCont.value > 0; + color.value = _style.color.withOpacity(_blinkOpacityController.value); + blink.value = show.value && _blinkOpacityController.value > 0; } } +/// Paints the editing cursor. class CursorPainter { - CursorPainter(this.editable, this.style, this.prototype, this.color, - this.devicePixelRatio); + CursorPainter( + this.editable, + this.style, + this.prototype, + this.color, + this.devicePixelRatio, + ); final RenderContentProxyBox? editable; final CursorStyle style; - final Rect? prototype; + final Rect prototype; final Color color; final double devicePixelRatio; + /// Paints cursor on [canvas] at specified [position]. void paint(Canvas canvas, Offset offset, TextPosition position) { - assert(prototype != null); - final caretOffset = editable!.getOffsetForCaret(position, prototype) + offset; - var caretRect = prototype!.shift(caretOffset); + var caretRect = prototype.shift(caretOffset); if (style.offset != null) { caretRect = caretRect.shift(style.offset!); } if (caretRect.left < 0.0) { + // For iOS the cursor may get clipped by the scroll view when + // it's located at a beginning of a line. We ensure that this + // does not happen here. This may result in the cursor being painted + // closer to the character on the right, but it's arguably better + // then painting clipped cursor (or even cursor completely hidden). caretRect = caretRect.shift(Offset(-caretRect.left, 0)); } @@ -185,6 +255,8 @@ class CursorPainter { case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: + // Override the height to take the full height of the glyph at the TextPosition + // when not on iOS. iOS has special handling that creates a taller caret. caretRect = Rect.fromLTWH( caretRect.left, caretRect.top - 2.0, @@ -194,6 +266,7 @@ class CursorPainter { break; case TargetPlatform.iOS: case TargetPlatform.macOS: + // Center the caret vertically along the text. caretRect = Rect.fromLTWH( caretRect.left, caretRect.top + (caretHeight - caretRect.height) / 2, diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 2bac327b..04a9567d 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -727,7 +727,7 @@ class RenderEditableTextLine extends RenderEditableBox { CursorPainter get _cursorPainter => CursorPainter( _body, cursorCont.style, - _caretPrototype, + _caretPrototype!, cursorCont.color.value, devicePixelRatio, );