@@ -94,6 +94,64 @@ class QuillRawEditorState extends EditorState
9494 bool get dirty => _dirty;
9595 bool _dirty = false ;
9696
97+ // Completely copied from flutter:
98+ // https://github.com/flutter/flutter/blob/3.29.0/packages/flutter/lib/src/widgets/editable_text.dart#L3741
99+ // Finds the closest scroll offset to the current scroll offset that fully
100+ // reveals the given caret rect. If the given rect's main axis extent is too
101+ // large to be fully revealed in `renderEditable`, it will be centered along
102+ // the main axis.
103+ //
104+ // If this is a multiline EditableText (which means the Editable can only
105+ // scroll vertically), the given rect's height will first be extended to match
106+ // `renderEditable.preferredLineHeight`, before the target scroll offset is
107+ // calculated.
108+ RevealedOffset _getOffsetToRevealCaret (Rect rect) {
109+ if (! _scrollController.position.allowImplicitScrolling) {
110+ return RevealedOffset (offset: _scrollController.offset, rect: rect);
111+ }
112+
113+ final Size editableSize = renderEditable.size;
114+ final double additionalOffset;
115+ final Offset unitOffset;
116+
117+ if (! _isMultiline) {
118+ additionalOffset = rect.width >= editableSize.width
119+ // Center `rect` if it's oversized.
120+ ? editableSize.width / 2 - rect.center.dx
121+ // Valid additional offsets range from (rect.right - size.width)
122+ // to (rect.left). Pick the closest one if out of range.
123+ : clampDouble (0.0 , rect.right - editableSize.width, rect.left);
124+ unitOffset = const Offset (1 , 0 );
125+ } else {
126+ // The caret is vertically centered within the line. Expand the caret's
127+ // height so that it spans the line because we're going to ensure that the
128+ // entire expanded caret is scrolled into view.
129+ final Rect expandedRect = Rect .fromCenter (
130+ center: rect.center,
131+ width: rect.width,
132+ height: math.max (rect.height, renderEditable.preferredLineHeight),
133+ );
134+
135+ additionalOffset = expandedRect.height >= editableSize.height
136+ ? editableSize.height / 2 - expandedRect.center.dy
137+ : clampDouble (
138+ 0.0 , expandedRect.bottom - editableSize.height, expandedRect.top);
139+ unitOffset = const Offset (0 , 1 );
140+ }
141+
142+ // No overscrolling when encountering tall fonts/scripts that extend past
143+ // the ascent.
144+ final double targetOffset = clampDouble (
145+ additionalOffset + _scrollController.offset,
146+ _scrollController.position.minScrollExtent,
147+ _scrollController.position.maxScrollExtent,
148+ );
149+
150+ final double offsetDelta = _scrollController.offset - targetOffset;
151+ return RevealedOffset (
152+ rect: rect.shift (unitOffset * offsetDelta), offset: targetOffset);
153+ }
154+
97155 @override
98156 void insertContent (KeyboardInsertedContent content) {
99157 assert (widget.config.contentInsertionConfiguration? .allowedMimeTypes
@@ -537,7 +595,6 @@ class QuillRawEditorState extends EditorState
537595 final requestKeyboardFocusOnCheckListChanged =
538596 widget.config.requestKeyboardFocusOnCheckListChanged;
539597 if (! (widget.config.checkBoxReadOnly ?? widget.config.readOnly)) {
540- _disableScrollControllerAnimateOnce = true ;
541598 final currentSelection = controller.selection.copyWith ();
542599 final attribute = value ? Attribute .checked : Attribute .unchecked;
543600
@@ -1031,7 +1088,7 @@ class QuillRawEditorState extends EditorState
10311088 if (ignoreCaret) {
10321089 return ;
10331090 }
1034- _showCaretOnScreen ( );
1091+ _scheduleShowCaretOnScreen (withAnimation : true );
10351092 _cursorCont.startOrStopCursorTimerIfNeeded (_hasFocus, controller.selection);
10361093 if (hasConnection) {
10371094 // To keep the cursor from blinking while typing, we want to restart the
@@ -1101,7 +1158,7 @@ class QuillRawEditorState extends EditorState
11011158 _updateOrDisposeSelectionOverlayIfNeeded ();
11021159 if (_hasFocus) {
11031160 WidgetsBinding .instance.addObserver (this );
1104- _showCaretOnScreen ( );
1161+ _scheduleShowCaretOnScreen (withAnimation : true );
11051162 } else {
11061163 WidgetsBinding .instance.removeObserver (this );
11071164 }
@@ -1120,53 +1177,95 @@ class QuillRawEditorState extends EditorState
11201177 return widget.config.linkActionPickerDelegate (context, link, linkNode);
11211178 }
11221179
1123- bool _showCaretOnScreenScheduled = false ;
1180+ // Animation configuration for scrolling the caret back on screen.
1181+ static const Duration _caretAnimationDuration = Duration (milliseconds: 100 );
1182+ static const Curve _caretAnimationCurve = Curves .fastOutSlowIn;
11241183
1125- // This is a workaround for checkbox tapping issue
1126- // https://github.com/singerdmx/flutter-quill/issues/619
1127- // We cannot treat {"list": "checked"} and {"list": "unchecked"} as
1128- // block of the same style
1129- // This causes controller.selection to go to offset 0
1130- bool _disableScrollControllerAnimateOnce = false ;
1184+ bool _showCaretOnScreenScheduled = false ;
11311185
1132- void _showCaretOnScreen () {
1133- if (! widget.config.showCursor || _showCaretOnScreenScheduled) {
1186+ // Completely copied from flutter:
1187+ // https://github.com/flutter/flutter/blob/3.29.0/packages/flutter/lib/src/widgets/editable_text.dart#L4228
1188+ void _scheduleShowCaretOnScreen ({required bool withAnimation}) {
1189+ if (_showCaretOnScreenScheduled) {
11341190 return ;
11351191 }
1136-
11371192 _showCaretOnScreenScheduled = true ;
1138- SchedulerBinding .instance.addPostFrameCallback ((_) {
1139- if (widget.config.scrollable || _scrollController.hasClients) {
1140- _showCaretOnScreenScheduled = false ;
1193+ SchedulerBinding .instance.addPostFrameCallback ((Duration _) {
1194+ _showCaretOnScreenScheduled = false ;
1195+ // Since we are in a post frame callback, check currentContext in case
1196+ // RenderEditable has been disposed (in which case it will be null).
1197+ final RenderEditable ? renderEditable =
1198+ _editableKey.currentContext? .findRenderObject () as RenderEditable ? ;
1199+ if (renderEditable == null ||
1200+ ! (renderEditable.selection? .isValid ?? false ) ||
1201+ ! _scrollController.hasClients) {
1202+ return ;
1203+ }
11411204
1142- if (! mounted) {
1143- return ;
1144- }
1205+ final double lineHeight = renderEditable.preferredLineHeight;
1206+
1207+ // Enlarge the target rect by scrollPadding to ensure that caret is not
1208+ // positioned directly at the edge after scrolling.
1209+ double bottomSpacing = widget.scrollPadding.bottom;
1210+ if (_selectionOverlay? .selectionControls != null ) {
1211+ final double handleHeight = _selectionOverlay! .selectionControls!
1212+ .getHandleSize (lineHeight)
1213+ .height;
1214+ final double interactiveHandleHeight =
1215+ math.max (handleHeight, kMinInteractiveDimension);
1216+ final Offset anchor =
1217+ _selectionOverlay! .selectionControls! .getHandleAnchor (
1218+ TextSelectionHandleType .collapsed,
1219+ lineHeight,
1220+ );
1221+ final double handleCenter = handleHeight / 2 - anchor.dy;
1222+ bottomSpacing =
1223+ math.max (handleCenter + interactiveHandleHeight / 2 , bottomSpacing);
1224+ }
11451225
1146- final viewport = RenderAbstractViewport .of (renderEditor);
1147- final editorOffset =
1148- renderEditor.localToGlobal (const Offset (0 , 0 ), ancestor: viewport);
1149- final offsetInViewport = _scrollController.offset + editorOffset.dy;
1226+ final EdgeInsets caretPadding =
1227+ widget.scrollPadding.copyWith (bottom: bottomSpacing);
11501228
1151- final offset = renderEditor.getOffsetToRevealCursor (
1152- _scrollController.position.viewportDimension,
1153- _scrollController.offset,
1154- offsetInViewport,
1155- );
1229+ final Rect caretRect =
1230+ renderEditable.getLocalRectForCaret (renderEditable.selection! .extent);
1231+ final RevealedOffset targetOffset = _getOffsetToRevealCaret (caretRect);
11561232
1157- if (offset != null ) {
1158- if (_disableScrollControllerAnimateOnce) {
1159- _disableScrollControllerAnimateOnce = false ;
1160- return ;
1161- }
1162- _scrollController.animateTo (
1163- math.min (offset, _scrollController.position.maxScrollExtent),
1164- duration: const Duration (milliseconds: 100 ),
1165- curve: Curves .fastOutSlowIn,
1166- );
1233+ final Rect rectToReveal;
1234+ final TextSelection selection = textEditingValue.selection;
1235+ if (selection.isCollapsed) {
1236+ rectToReveal = targetOffset.rect;
1237+ } else {
1238+ final List <TextBox > selectionBoxes =
1239+ renderEditable.getBoxesForSelection (selection);
1240+ // selectionBoxes may be empty if, for example, the selection does not
1241+ // encompass a full character, like if it only contained part of an
1242+ // extended grapheme cluster.
1243+ if (selectionBoxes.isEmpty) {
1244+ rectToReveal = targetOffset.rect;
1245+ } else {
1246+ rectToReveal = selection.baseOffset < selection.extentOffset
1247+ ? selectionBoxes.last.toRect ()
1248+ : selectionBoxes.first.toRect ();
11671249 }
11681250 }
1169- });
1251+
1252+ if (withAnimation) {
1253+ _scrollController.animateTo (
1254+ targetOffset.offset,
1255+ duration: _caretAnimationDuration,
1256+ curve: _caretAnimationCurve,
1257+ );
1258+ renderEditable.showOnScreen (
1259+ rect: caretPadding.inflateRect (rectToReveal),
1260+ duration: _caretAnimationDuration,
1261+ curve: _caretAnimationCurve,
1262+ );
1263+ } else {
1264+ _scrollController.jumpTo (targetOffset.offset);
1265+ renderEditable.showOnScreen (
1266+ rect: caretPadding.inflateRect (rectToReveal));
1267+ }
1268+ }, debugLabel: 'EditableText.showCaret' );
11701269 }
11711270
11721271 /// The renderer for this widget's editor descendant.
@@ -1196,10 +1295,10 @@ class QuillRawEditorState extends EditorState
11961295 /// delay 500 milliseconds for waiting keyboard show up
11971296 Future .delayed (
11981297 const Duration (milliseconds: 500 ),
1199- _showCaretOnScreen ,
1298+ () => _scheduleShowCaretOnScreen (withAnimation : true ) ,
12001299 );
12011300 } else {
1202- _showCaretOnScreen ( );
1301+ _scheduleShowCaretOnScreen (withAnimation : true );
12031302 }
12041303 } else {
12051304 widget.config.focusNode.requestFocus ();
0 commit comments