From 13d542680e538f7efa0d88b152ef4b2b96f597a3 Mon Sep 17 00:00:00 2001 From: X Code Date: Fri, 10 Dec 2021 00:34:18 -0800 Subject: [PATCH] iOS - floating cursor --- lib/src/widgets/box.dart | 4 + lib/src/widgets/cursor.dart | 11 + lib/src/widgets/editor.dart | 196 ++++++++++++++++-- lib/src/widgets/float_cursor.dart | 31 +++ lib/src/widgets/raw_editor.dart | 43 ++-- ..._editor_state_text_input_client_mixin.dart | 115 +++++++++- lib/src/widgets/simple_viewer.dart | 48 +++-- lib/src/widgets/text_block.dart | 10 + lib/src/widgets/text_line.dart | 55 ++++- 9 files changed, 454 insertions(+), 59 deletions(-) create mode 100644 lib/src/widgets/float_cursor.dart diff --git a/lib/src/widgets/box.dart b/lib/src/widgets/box.dart index 566c1a92..b754b5c2 100644 --- a/lib/src/widgets/box.dart +++ b/lib/src/widgets/box.dart @@ -119,4 +119,8 @@ abstract class RenderEditableBox extends RenderBox { /// Returns the [Rect] in local coordinates for the caret at the given text /// position. Rect getLocalRectForCaret(TextPosition position); + + /// Returns the [Rect] of the caret prototype at the given text + /// position. [Rect] starts at origin. + Rect getCaretPrototype(TextPosition position); } diff --git a/lib/src/widgets/cursor.dart b/lib/src/widgets/cursor.dart index 0fe57355..92666d82 100644 --- a/lib/src/widgets/cursor.dart +++ b/lib/src/widgets/cursor.dart @@ -131,6 +131,17 @@ class CursorCont extends ChangeNotifier { Timer? _cursorTimer; bool _targetCursorVisibility = false; + final ValueNotifier _floatingCursorTextPosition = + ValueNotifier(null); + + ValueNotifier get floatingCursorTextPosition => + _floatingCursorTextPosition; + + void setFloatingCursorTextPosition(TextPosition? position) => + _floatingCursorTextPosition.value = position; + + bool get isFloatingCursorActive => floatingCursorTextPosition.value != null; + CursorStyle _style; CursorStyle get style => _style; set style(CursorStyle value) { diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index c12c314d..17ef54a8 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:string_validator/string_validator.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -22,6 +23,7 @@ import 'controller.dart'; import 'cursor.dart'; import 'default_styles.dart'; import 'delegate.dart'; +import 'float_cursor.dart'; import 'image.dart'; import 'raw_editor.dart'; import 'text_selection.dart'; @@ -56,6 +58,10 @@ abstract class EditorState extends State { EditorTextSelectionOverlay? getSelectionOverlay(); + /// Controls the floating cursor animation when it is released. + /// The floating cursor is animated to merge with the regular cursor. + AnimationController get floatingCursorResetController; + bool showToolbar(); void hideToolbar(); @@ -88,9 +94,24 @@ abstract class RenderAbstractEditor { /// selection that contains some text but whose ends meet in the middle). TextPosition getPositionForOffset(Offset offset); + /// Returns the local coordinates of the endpoints of the given selection. + /// + /// If the selection is collapsed (and therefore occupies a single point), the + /// returned list is of length one. Otherwise, the selection is not collapsed + /// and the returned list is of length two. In this case, however, the two + /// points might actually be co-located (e.g., because of a bidirectional + /// selection that contains some text but whose ends meet in the middle). List getEndpointsForSelection( TextSelection textSelection); + /// Sets the screen position of the floating cursor and the text position + /// closest to the cursor. + /// `resetLerpValue` drives the size of the floating cursor. + /// See [EditorState.floatingCursorResetController]. + void setFloatingCursor(FloatingCursorDragState dragState, + Offset lastBoundedOffset, TextPosition lastTextPosition, + {double? resetLerpValue}); + /// If [ignorePointer] is false (the default) then this method is called by /// the internal gesture recognizer's [TapGestureRecognizer.onTapDown] /// callback. @@ -646,25 +667,39 @@ class _QuillEditorSelectionGestureDetectorBuilder } } +/// Signature for the callback that reports when the user changes the selection +/// (including the cursor location). +/// +/// Used by [RenderEditor.onSelectionChanged]. typedef TextSelectionChangedHandler = void Function( TextSelection selection, SelectionChangedCause cause); +// The padding applied to text field. Used to determine the bounds when +// moving the floating cursor. +const EdgeInsets _kFloatingCursorAddedMargin = EdgeInsets.fromLTRB(4, 4, 4, 5); + +// The additional size on the x and y axis with which to expand the prototype +// cursor to render the floating cursor in pixels. +const EdgeInsets _kFloatingCaretSizeIncrease = + EdgeInsets.symmetric(horizontal: 0.5, vertical: 1); + class RenderEditor extends RenderEditableContainerBox implements RenderAbstractEditor { RenderEditor( - ViewportOffset? offset, - List? children, - TextDirection textDirection, - double scrollBottomInset, - EdgeInsetsGeometry padding, - this.document, - this.selection, - this._hasFocus, - this.onSelectionChanged, - this._startHandleLayerLink, - this._endHandleLayerLink, - EdgeInsets floatingCursorAddedMargin, - ) : super( + ViewportOffset? offset, + List? children, + TextDirection textDirection, + double scrollBottomInset, + EdgeInsetsGeometry padding, + this.document, + this.selection, + this._hasFocus, + this.onSelectionChanged, + this._startHandleLayerLink, + this._endHandleLayerLink, + EdgeInsets floatingCursorAddedMargin, + this._cursorController) + : super( children, document.root, textDirection, @@ -672,6 +707,8 @@ class RenderEditor extends RenderEditableContainerBox padding, ); + final CursorCont _cursorController; + Document document; TextSelection selection; bool _hasFocus = false; @@ -983,9 +1020,20 @@ class RenderEditor extends RenderEditableContainerBox @override void paint(PaintingContext context, Offset offset) { + if (_hasFocus && + _cursorController.show.value && + !_cursorController.style.paintAboveText) { + _paintFloatingCursor(context, offset); + } defaultPaint(context, offset); _updateSelectionExtentsVisibility(offset + _paintOffset); _paintHandleLayers(context, getEndpointsForSelection(selection)); + + if (_hasFocus && + _cursorController.show.value && + _cursorController.style.paintAboveText) { + _paintFloatingCursor(context, offset); + } } @override @@ -1097,6 +1145,128 @@ class RenderEditor extends RenderEditableContainerBox final boxParentData = targetChild.parentData as BoxParentData; return childLocalRect.shift(Offset(0, boxParentData.offset.dy)); } + + // Start floating cursor + + FloatingCursorPainter get _floatingCursorPainter => FloatingCursorPainter( + floatingCursorRect: _floatingCursorRect, + style: _cursorController.style, + ); + + bool _floatingCursorOn = false; + Rect? _floatingCursorRect; + + TextPosition get floatingCursorTextPosition => _floatingCursorTextPosition; + late TextPosition _floatingCursorTextPosition; + + // The relative origin in relation to the distance the user has theoretically + // dragged the floating cursor offscreen. + // This value is used to account for the difference + // in the rendering position and the raw offset value. + Offset _relativeOrigin = Offset.zero; + Offset? _previousOffset; + bool _resetOriginOnLeft = false; + bool _resetOriginOnRight = false; + bool _resetOriginOnTop = false; + bool _resetOriginOnBottom = false; + + /// Returns the position within the editor closest to the raw cursor offset. + Offset calculateBoundedFloatingCursorOffset( + Offset rawCursorOffset, double preferredLineHeight) { + var deltaPosition = Offset.zero; + final topBound = _kFloatingCursorAddedMargin.top; + final bottomBound = + size.height - preferredLineHeight + _kFloatingCursorAddedMargin.bottom; + final leftBound = _kFloatingCursorAddedMargin.left; + final rightBound = size.width - _kFloatingCursorAddedMargin.right; + + if (_previousOffset != null) { + deltaPosition = rawCursorOffset - _previousOffset!; + } + + // If the raw cursor offset has gone off an edge, + // we want to reset the relative origin of + // the dragging when the user drags back into the field. + if (_resetOriginOnLeft && deltaPosition.dx > 0) { + _relativeOrigin = + Offset(rawCursorOffset.dx - leftBound, _relativeOrigin.dy); + _resetOriginOnLeft = false; + } else if (_resetOriginOnRight && deltaPosition.dx < 0) { + _relativeOrigin = + Offset(rawCursorOffset.dx - rightBound, _relativeOrigin.dy); + _resetOriginOnRight = false; + } + if (_resetOriginOnTop && deltaPosition.dy > 0) { + _relativeOrigin = + Offset(_relativeOrigin.dx, rawCursorOffset.dy - topBound); + _resetOriginOnTop = false; + } else if (_resetOriginOnBottom && deltaPosition.dy < 0) { + _relativeOrigin = + Offset(_relativeOrigin.dx, rawCursorOffset.dy - bottomBound); + _resetOriginOnBottom = false; + } + + final currentX = rawCursorOffset.dx - _relativeOrigin.dx; + final currentY = rawCursorOffset.dy - _relativeOrigin.dy; + final double adjustedX = + math.min(math.max(currentX, leftBound), rightBound); + final double adjustedY = + math.min(math.max(currentY, topBound), bottomBound); + final adjustedOffset = Offset(adjustedX, adjustedY); + + if (currentX < leftBound && deltaPosition.dx < 0) { + _resetOriginOnLeft = true; + } else if (currentX > rightBound && deltaPosition.dx > 0) { + _resetOriginOnRight = true; + } + if (currentY < topBound && deltaPosition.dy < 0) { + _resetOriginOnTop = true; + } else if (currentY > bottomBound && deltaPosition.dy > 0) { + _resetOriginOnBottom = true; + } + + _previousOffset = rawCursorOffset; + + return adjustedOffset; + } + + @override + void setFloatingCursor(FloatingCursorDragState dragState, + Offset boundedOffset, TextPosition textPosition, + {double? resetLerpValue}) { + if (dragState == FloatingCursorDragState.Start) { + _relativeOrigin = Offset.zero; + _previousOffset = null; + _resetOriginOnBottom = false; + _resetOriginOnTop = false; + _resetOriginOnRight = false; + _resetOriginOnBottom = false; + } + _floatingCursorOn = dragState != FloatingCursorDragState.End; + if (_floatingCursorOn) { + _floatingCursorTextPosition = textPosition; + final sizeAdjustment = resetLerpValue != null + ? EdgeInsets.lerp( + _kFloatingCaretSizeIncrease, EdgeInsets.zero, resetLerpValue)! + : _kFloatingCaretSizeIncrease; + final child = childAtPosition(textPosition); + final caretPrototype = + child.getCaretPrototype(child.globalToLocalPosition(textPosition)); + _floatingCursorRect = + sizeAdjustment.inflateRect(caretPrototype).shift(boundedOffset); + _cursorController + .setFloatingCursorTextPosition(_floatingCursorTextPosition); + } else { + _floatingCursorRect = null; + _cursorController.setFloatingCursorTextPosition(null); + } + } + + void _paintFloatingCursor(PaintingContext context, Offset offset) { + _floatingCursorPainter.paint(context.canvas); + } + +// End floating cursor } class EditableContainerParentData diff --git a/lib/src/widgets/float_cursor.dart b/lib/src/widgets/float_cursor.dart new file mode 100644 index 00000000..b67efc29 --- /dev/null +++ b/lib/src/widgets/float_cursor.dart @@ -0,0 +1,31 @@ +// The corner radius of the floating cursor in pixels. +import 'dart:ui'; + +import '../../widgets/cursor.dart'; + +const Radius _kFloatingCaretRadius = Radius.circular(1); + +/// Floating painter responsible for painting the floating cursor when +/// floating mode is activated +class FloatingCursorPainter { + FloatingCursorPainter({ + required this.floatingCursorRect, + required this.style, + }); + + CursorStyle style; + + Rect? floatingCursorRect; + + final Paint floatingCursorPaint = Paint(); + + void paint(Canvas canvas) { + final floatingCursorRect = this.floatingCursorRect; + final floatingCursorColor = style.color.withOpacity(0.75); + if (floatingCursorRect == null) return; + canvas.drawRRect( + RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCaretRadius), + floatingCursorPaint..color = floatingCursorColor, + ); + } +} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index f42deef2..eef259f6 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -126,6 +126,7 @@ class RawEditorState extends EditorState ScrollController get scrollController => _scrollController; late ScrollController _scrollController; + // Cursors late CursorCont _cursorCont; // Focus @@ -133,6 +134,7 @@ class RawEditorState extends EditorState FocusAttachment? _focusAttachment; bool get _hasFocus => widget.focusNode.hasFocus; + // Theme DefaultStyles? _styles; final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); @@ -162,6 +164,7 @@ class RawEditorState extends EditorState document: _doc, selection: widget.controller.selection, hasFocus: _hasFocus, + cursorController: _cursorCont, textDirection: _textDirection, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, @@ -196,6 +199,7 @@ class RawEditorState extends EditorState onSelectionChanged: _handleSelectionChanged, scrollBottomInset: widget.scrollBottomInset, padding: widget.padding, + cursorController: _cursorCont, children: _buildChildren(_doc, context), ), ), @@ -362,6 +366,11 @@ class RawEditorState extends EditorState tickerProvider: this, ); + // Floating cursor + _floatingCursorResetController = AnimationController(vsync: this); + _floatingCursorResetController.addListener(onFloatingCursorResetTick); + + // Keyboard _keyboardListener = KeyboardEventHandler( handleCursorMovement, handleShortcut, @@ -727,6 +736,12 @@ class RawEditorState extends EditorState @override bool get readOnly => widget.readOnly; + + @override + AnimationController get floatingCursorResetController => + _floatingCursorResetController; + + late AnimationController _floatingCursorResetController; } class _Editor extends MultiChildRenderObjectWidget { @@ -741,6 +756,7 @@ class _Editor extends MultiChildRenderObjectWidget { required this.endHandleLayerLink, required this.onSelectionChanged, required this.scrollBottomInset, + required this.cursorController, this.padding = EdgeInsets.zero, this.offset, }) : super(key: key, children: children); @@ -755,23 +771,24 @@ class _Editor extends MultiChildRenderObjectWidget { final TextSelectionChangedHandler onSelectionChanged; final double scrollBottomInset; final EdgeInsetsGeometry padding; + final CursorCont cursorController; @override RenderEditor createRenderObject(BuildContext context) { return RenderEditor( - offset, - null, - textDirection, - scrollBottomInset, - padding, - document, - selection, - hasFocus, - onSelectionChanged, - startHandleLayerLink, - endHandleLayerLink, - const EdgeInsets.fromLTRB(4, 4, 4, 5), - ); + offset, + null, + textDirection, + scrollBottomInset, + padding, + document, + selection, + hasFocus, + onSelectionChanged, + startHandleLayerLink, + endHandleLayerLink, + const EdgeInsets.fromLTRB(4, 4, 4, 5), + cursorController); } @override diff --git a/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart index 37c5ece1..cfb13e62 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; + +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -185,9 +188,119 @@ mixin RawEditorStateTextInputClientMixin on EditorState // no-op } + // 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); + + // The original position of the caret on FloatingCursorDragState.start. + Rect? _startCaretRect; + + // The most recent text position as determined by the location of the floating + // cursor. + TextPosition? _lastTextPosition; + + // The offset of the floating cursor as determined from the start call. + Offset? _pointOffsetOrigin; + + // The most recent position of the floating cursor. + Offset? _lastBoundedOffset; + + // Because the center of the cursor is preferredLineHeight / 2 below the touch + // origin, but the touch origin is used to determine which line the cursor is + // on, we need this offset to correctly render and move the cursor. + Offset _floatingCursorOffset(TextPosition textPosition) => + Offset(0, getRenderEditor()!.preferredLineHeight(textPosition) / 2); + @override void updateFloatingCursor(RawFloatingCursorPoint point) { - throw UnimplementedError(); + switch (point.state) { + case FloatingCursorDragState.Start: + if (floatingCursorResetController.isAnimating) { + floatingCursorResetController.stop(); + onFloatingCursorResetTick(); + } + // We want to send in points that are centered around a (0,0) origin, so + // we cache the position. + _pointOffsetOrigin = point.offset; + + final currentTextPosition = + TextPosition(offset: getRenderEditor()!.selection.baseOffset); + _startCaretRect = + getRenderEditor()!.getLocalRectForCaret(currentTextPosition); + + _lastBoundedOffset = _startCaretRect!.center - + _floatingCursorOffset(currentTextPosition); + _lastTextPosition = currentTextPosition; + getRenderEditor()!.setFloatingCursor( + point.state, _lastBoundedOffset!, _lastTextPosition!); + break; + case FloatingCursorDragState.Update: + assert(_lastTextPosition != null, 'Last text position was not set'); + final floatingCursorOffset = _floatingCursorOffset(_lastTextPosition!); + final centeredPoint = point.offset! - _pointOffsetOrigin!; + final rawCursorOffset = + _startCaretRect!.center + centeredPoint - floatingCursorOffset; + + final preferredLineHeight = + getRenderEditor()!.preferredLineHeight(_lastTextPosition!); + _lastBoundedOffset = + getRenderEditor()!.calculateBoundedFloatingCursorOffset( + rawCursorOffset, + preferredLineHeight, + ); + _lastTextPosition = getRenderEditor()!.getPositionForOffset( + getRenderEditor()! + .localToGlobal(_lastBoundedOffset! + floatingCursorOffset)); + getRenderEditor()!.setFloatingCursor( + point.state, _lastBoundedOffset!, _lastTextPosition!); + final newSelection = TextSelection.collapsed( + offset: _lastTextPosition!.offset, + affinity: _lastTextPosition!.affinity); + // Setting selection as floating cursor moves will have scroll view + // bring background cursor into view + getRenderEditor()! + .onSelectionChanged(newSelection, SelectionChangedCause.forcePress); + break; + case FloatingCursorDragState.End: + // We skip animation if no update has happened. + if (_lastTextPosition != null && _lastBoundedOffset != null) { + floatingCursorResetController + ..value = 0.0 + ..animateTo(1, + duration: _floatingCursorResetTime, curve: Curves.decelerate); + } + break; + } + } + + /// Specifies the floating cursor dimensions and position based + /// the animation controller value. + /// The floating cursor is resized + /// (see [RenderAbstractEditor.setFloatingCursor]) + /// and repositioned (linear interpolation between position of floating cursor + /// and current position of background cursor) + void onFloatingCursorResetTick() { + final finalPosition = + getRenderEditor()!.getLocalRectForCaret(_lastTextPosition!).centerLeft - + _floatingCursorOffset(_lastTextPosition!); + if (floatingCursorResetController.isCompleted) { + getRenderEditor()!.setFloatingCursor( + FloatingCursorDragState.End, finalPosition, _lastTextPosition!); + _startCaretRect = null; + _lastTextPosition = null; + _pointOffsetOrigin = null; + _lastBoundedOffset = null; + } else { + final lerpValue = floatingCursorResetController.value; + final lerpX = + lerpDouble(_lastBoundedOffset!.dx, finalPosition.dx, lerpValue)!; + final lerpY = + lerpDouble(_lastBoundedOffset!.dy, finalPosition.dy, lerpValue)!; + + getRenderEditor()!.setFloatingCursor(FloatingCursorDragState.Update, + Offset(lerpX, lerpY), _lastTextPosition!, + resetLerpValue: lerpValue); + } } @override diff --git a/lib/src/widgets/simple_viewer.dart b/lib/src/widgets/simple_viewer.dart index 86dcd4f3..9a2dc221 100644 --- a/lib/src/widgets/simple_viewer.dart +++ b/lib/src/widgets/simple_viewer.dart @@ -150,15 +150,15 @@ class _QuillSimpleViewerState extends State link: _toolbarLayerLink, child: Semantics( child: _SimpleViewer( - document: _doc, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _nullSelectionChanged, - scrollBottomInset: widget.scrollBottomInset, - padding: widget.padding, - children: _buildChildren(_doc, context), - ), + document: _doc, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _nullSelectionChanged, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + cursorController: _cursorCont, + children: _buildChildren(_doc, context)), ), ); @@ -315,6 +315,7 @@ class _SimpleViewer extends MultiChildRenderObjectWidget { required this.endHandleLayerLink, required this.onSelectionChanged, required this.scrollBottomInset, + required this.cursorController, this.offset, this.padding = EdgeInsets.zero, Key? key, @@ -328,24 +329,25 @@ class _SimpleViewer extends MultiChildRenderObjectWidget { final TextSelectionChangedHandler onSelectionChanged; final double scrollBottomInset; final EdgeInsetsGeometry padding; + final CursorCont cursorController; @override RenderEditor createRenderObject(BuildContext context) { return RenderEditor( - offset, - null, - textDirection, - scrollBottomInset, - padding, - document, - const TextSelection(baseOffset: 0, extentOffset: 0), - false, - // hasFocus, - onSelectionChanged, - startHandleLayerLink, - endHandleLayerLink, - const EdgeInsets.fromLTRB(4, 4, 4, 5), - ); + offset, + null, + textDirection, + scrollBottomInset, + padding, + document, + const TextSelection(baseOffset: 0, extentOffset: 0), + false, + // hasFocus, + onSelectionChanged, + startHandleLayerLink, + endHandleLayerLink, + const EdgeInsets.fromLTRB(4, 4, 4, 5), + cursorController); } @override diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index 245cd332..33b1c351 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -560,6 +560,16 @@ class RenderEditableTextBlock extends RenderEditableContainerBox affinity: position.affinity, ); } + + @override + Rect getCaretPrototype(TextPosition position) { + final child = childAtPosition(position); + final localPosition = TextPosition( + offset: position.offset - child.getContainer().offset, + affinity: position.affinity, + ); + return child.getCaretPrototype(localPosition); + } } class _EditableBlock extends MultiChildRenderObjectWidget { diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 2291dfc3..5e365272 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -387,7 +387,7 @@ class RenderEditableTextLine extends RenderEditableBox { EdgeInsets? _resolvedPadding; bool? _containsCursor; List? _selectedRects; - Rect? _caretPrototype; + late Rect _caretPrototype; final Map children = {}; Iterable get _children sync* { @@ -501,8 +501,11 @@ class RenderEditableTextLine extends RenderEditableBox { } bool containsCursor() { - return _containsCursor ??= textSelection.isCollapsed && - line.containsOffset(textSelection.baseOffset); + return _containsCursor ??= cursorCont.isFloatingCursorActive + ? line + .containsOffset(cursorCont.floatingCursorTextPosition.value!.offset) + : textSelection.isCollapsed && + line.containsOffset(textSelection.baseOffset); } RenderBox? _updateChild( @@ -638,6 +641,17 @@ class RenderEditableTextLine extends RenderEditableBox { cursorCont.style.height ?? preferredLineHeight(const TextPosition(offset: 0)); + // TODO: This is no longer producing the highest-fidelity caret + // heights for Android, especially when non-alphabetic languages + // are involved. The current implementation overrides the height set + // here with the full measured height of the text on Android which looks + // superior (subjectively and in terms of fidelity) in _paintCaret. We + // should rework this properly to once again match the platform. The constant + // _kCaretHeightOffset scales poorly for small font sizes. + // + /// On iOS, the cursor is taller than the cursor on Android. The height + /// of the cursor for iOS is approximate and obtained through an eyeball + /// comparison. void _computeCaretPrototype() { switch (defaultTargetPlatform) { case TargetPlatform.iOS: @@ -655,12 +669,24 @@ class RenderEditableTextLine extends RenderEditableBox { } } + void _onFloatingCursorChange() { + _containsCursor = null; + markNeedsPaint(); + } + + // End caret implementation + + // + + // Start render box overrides + @override void attach(covariant PipelineOwner owner) { super.attach(owner); for (final child in _children) { child.attach(owner); } + cursorCont.floatingCursorTextPosition.addListener(_onFloatingCursorChange); if (containsCursor()) { cursorCont.addListener(markNeedsLayout); cursorCont.color.addListener(safeMarkNeedsPaint); @@ -673,6 +699,8 @@ class RenderEditableTextLine extends RenderEditableBox { for (final child in _children) { child.detach(); } + cursorCont.floatingCursorTextPosition + .removeListener(_onFloatingCursorChange); if (containsCursor()) { cursorCont.removeListener(markNeedsLayout); cursorCont.color.removeListener(safeMarkNeedsPaint); @@ -817,8 +845,10 @@ class RenderEditableTextLine extends RenderEditableBox { CursorPainter get _cursorPainter => CursorPainter( editable: _body, style: cursorCont.style, - prototype: _caretPrototype!, - color: cursorCont.color.value, + prototype: _caretPrototype, + color: cursorCont.isFloatingCursorActive + ? cursorCont.style.backgroundColor + : cursorCont.color.value, devicePixelRatio: devicePixelRatio, ); @@ -873,10 +903,14 @@ class RenderEditableTextLine extends RenderEditableBox { void _paintCursor( PaintingContext context, Offset effectiveOffset, bool lineHasEmbed) { - final position = TextPosition( - offset: textSelection.extentOffset - line.documentOffset, - affinity: textSelection.base.affinity, - ); + final position = cursorCont.isFloatingCursorActive + ? TextPosition( + offset: cursorCont.floatingCursorTextPosition.value!.offset - + line.documentOffset, + affinity: cursorCont.floatingCursorTextPosition.value!.affinity) + : TextPosition( + offset: textSelection.extentOffset - line.documentOffset, + affinity: textSelection.base.affinity); _cursorPainter.paint( context.canvas, effectiveOffset, position, lineHasEmbed); } @@ -921,6 +955,9 @@ class RenderEditableTextLine extends RenderEditableBox { } markNeedsPaint(); } + + @override + Rect getCaretPrototype(TextPosition position) => _caretPrototype; } class _TextLineElement extends RenderObjectElement {