Skip to content

Commit 6f8624e

Browse files
committed
Bring into view upon selection extension
When extending selection, have scroll controller jump to the most appropriate offset to display selection extension
1 parent 30d920f commit 6f8624e

File tree

7 files changed

+249
-15
lines changed

7 files changed

+249
-15
lines changed

lib/src/widgets/box.dart

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,107 @@ abstract class RenderContentProxyBox implements RenderBox {
1616
List<TextBox> getBoxesForSelection(TextSelection textSelection);
1717
}
1818

19+
/// Base class for render boxes of editable content.
20+
///
21+
/// Implementations of this class usually work as a wrapper around
22+
/// regular (non-editable) render boxes which implement
23+
/// [RenderContentProxyBox].
1924
abstract class RenderEditableBox extends RenderBox {
25+
/// The document node represented by this render box.
2026
Container getContainer();
2127

28+
/// Returns preferred line height at specified `position` in text.
29+
///
30+
/// The `position` parameter must be relative to the [node]'s content.
2231
double preferredLineHeight(TextPosition position);
2332

33+
/// Returns the offset at which to paint the caret.
34+
///
35+
/// The `position` parameter must be relative to the [node]'s content.
36+
///
37+
/// Valid only after [layout].
2438
Offset getOffsetForCaret(TextPosition position);
2539

40+
/// Returns the position within the text for the given pixel offset.
41+
///
42+
/// The `offset` parameter must be local to this box coordinate system.
43+
///
44+
/// Valid only after [layout].
2645
TextPosition getPositionForOffset(Offset offset);
2746

47+
/// Returns the position relative to the [node] content
48+
///
49+
/// The `position` must be within the [node] content
50+
TextPosition globalToLocalPosition(TextPosition position);
51+
52+
/// Returns the position within the text which is on the line above the given
53+
/// `position`.
54+
///
55+
/// The `position` parameter must be relative to the [node] content.
56+
///
57+
/// Primarily used with multi-line or soft-wrapping text.
58+
///
59+
/// Can return `null` which indicates that the `position` is at the topmost
60+
/// line in the text already.
2861
TextPosition? getPositionAbove(TextPosition position);
2962

63+
/// Returns the position within the text which is on the line below the given
64+
/// `position`.
65+
///
66+
/// The `position` parameter must be relative to the [node] content.
67+
///
68+
/// Primarily used with multi-line or soft-wrapping text.
69+
///
70+
/// Can return `null` which indicates that the `position` is at the bottommost
71+
/// line in the text already.
3072
TextPosition? getPositionBelow(TextPosition position);
3173

74+
/// Returns the text range of the word at the given offset. Characters not
75+
/// part of a word, such as spaces, symbols, and punctuation, have word breaks
76+
/// on both sides. In such cases, this method will return a text range that
77+
/// contains the given text position.
78+
///
79+
/// Word boundaries are defined more precisely in Unicode Standard Annex #29
80+
/// <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
81+
///
82+
/// The `position` parameter must be relative to the [node]'s content.
83+
///
84+
/// Valid only after [layout].
3285
TextRange getWordBoundary(TextPosition position);
3386

87+
/// Returns the text range of the line at the given offset.
88+
///
89+
/// The newline, if any, is included in the range.
90+
///
91+
/// The `position` parameter must be relative to the [node]'s content.
92+
///
93+
/// Valid only after [layout].
3494
TextRange getLineBoundary(TextPosition position);
3595

96+
/// Returns a list of rects that bound the given selection.
97+
///
98+
/// A given selection might have more than one rect if this text painter
99+
/// contains bidirectional text because logically contiguous text might not be
100+
/// visually contiguous.
101+
///
102+
/// Valid only after [layout].
103+
// List<TextBox> getBoxesForSelection(TextSelection selection);
104+
105+
/// Returns a point for the base selection handle used on touch-oriented
106+
/// devices.
107+
///
108+
/// The `selection` parameter is expected to be in local offsets to this
109+
/// render object's [node].
36110
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection);
37111

112+
/// Returns a point for the extent selection handle used on touch-oriented
113+
/// devices.
114+
///
115+
/// The `selection` parameter is expected to be in local offsets to this
116+
/// render object's [node].
38117
TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection);
118+
119+
/// Returns the [Rect] in local coordinates for the caret at the given text
120+
/// position.
121+
Rect getLocalRectForCaret(TextPosition position);
39122
}

lib/src/widgets/cursor.dart

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,7 @@ class CursorPainter {
292292
}
293293
}
294294

295-
final pixelPerfectOffset =
296-
_getPixelPerfectCursorOffset(editable!, caretRect, devicePixelRatio);
295+
final pixelPerfectOffset = _getPixelPerfectCursorOffset(caretRect);
297296
if (!pixelPerfectOffset.isFinite) {
298297
return;
299298
}
@@ -309,11 +308,9 @@ class CursorPainter {
309308
}
310309

311310
Offset _getPixelPerfectCursorOffset(
312-
RenderContentProxyBox editable,
313311
Rect caretRect,
314-
double devicePixelRatio,
315312
) {
316-
final caretPosition = editable.localToGlobal(caretRect.topLeft);
313+
final caretPosition = editable!.localToGlobal(caretRect.topLeft);
317314
final pixelMultiple = 1.0 / devicePixelRatio;
318315

319316
final pixelPerfectOffsetX = caretPosition.dx.isFinite

lib/src/widgets/editor.dart

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const linkPrefixes = [
4747
];
4848

4949
abstract class EditorState extends State<RawEditor> {
50+
ScrollController get scrollController;
51+
5052
TextEditingValue getTextEditingValue();
5153

5254
void setTextEditingValue(TextEditingValue value);
@@ -62,32 +64,75 @@ abstract class EditorState extends State<RawEditor> {
6264
void requestKeyboard();
6365
}
6466

67+
/// Base interface for editable render objects.
6568
abstract class RenderAbstractEditor {
6669
TextSelection selectWordAtPosition(TextPosition position);
6770

6871
TextSelection selectLineAtPosition(TextPosition position);
6972

73+
/// Returns preferred line height at specified `position` in text.
7074
double preferredLineHeight(TextPosition position);
7175

76+
/// Returns [Rect] for caret in local coordinates
77+
///
78+
/// Useful to enforce visibility of full caret at given position
79+
Rect getLocalRectForCaret(TextPosition position);
80+
81+
/// Returns the local coordinates of the endpoints of the given selection.
82+
///
83+
/// If the selection is collapsed (and therefore occupies a single point), the
84+
/// returned list is of length one. Otherwise, the selection is not collapsed
85+
/// and the returned list is of length two. In this case, however, the two
86+
/// points might actually be co-located (e.g., because of a bidirectional
87+
/// selection that contains some text but whose ends meet in the middle).
7288
TextPosition getPositionForOffset(Offset offset);
7389

7490
List<TextSelectionPoint> getEndpointsForSelection(
7591
TextSelection textSelection);
7692

93+
/// If [ignorePointer] is false (the default) then this method is called by
94+
/// the internal gesture recognizer's [TapGestureRecognizer.onTapDown]
95+
/// callback.
96+
///
97+
/// When [ignorePointer] is true, an ancestor widget must respond to tap
98+
/// down events by calling this method.
7799
void handleTapDown(TapDownDetails details);
78100

101+
/// Selects the set words of a paragraph in a given range of global positions.
102+
///
103+
/// The first and last endpoints of the selection will always be at the
104+
/// beginning and end of a word respectively.
105+
///
106+
/// {@macro flutter.rendering.editable.select}
79107
void selectWordsInRange(
80108
Offset from,
81109
Offset to,
82110
SelectionChangedCause cause,
83111
);
84112

113+
/// Move the selection to the beginning or end of a word.
114+
///
115+
/// {@macro flutter.rendering.editable.select}
85116
void selectWordEdge(SelectionChangedCause cause);
86117

118+
/// Select text between the global positions [from] and [to].
87119
void selectPositionAt(Offset from, Offset to, SelectionChangedCause cause);
88120

121+
/// Select a word around the location of the last tap down.
122+
///
123+
/// {@macro flutter.rendering.editable.select}
89124
void selectWord(SelectionChangedCause cause);
90125

126+
/// Move selection to the location of the last tap down.
127+
///
128+
/// {@template flutter.rendering.editable.select}
129+
/// This method is mainly used to translate user inputs in global positions
130+
/// into a [TextSelection]. When used in conjunction with a [EditableText],
131+
/// the selection change is fed back into [TextEditingController.selection].
132+
///
133+
/// If you have a [TextEditingController], it's generally easier to
134+
/// programmatically manipulate its `value` or `selection` directly.
135+
/// {@endtemplate}
91136
void selectPosition(SelectionChangedCause cause);
92137
}
93138

@@ -988,7 +1033,8 @@ class RenderEditor extends RenderEditableContainerBox
9881033

9891034
final caretTop = endpoint.point.dy -
9901035
child.preferredLineHeight(TextPosition(
991-
offset: selection.extentOffset - child.getContainer().offset)) -
1036+
offset:
1037+
selection.extentOffset - child.getContainer().documentOffset)) -
9921038
kMargin +
9931039
offsetInViewport +
9941040
scrollBottomInset;
@@ -1005,6 +1051,17 @@ class RenderEditor extends RenderEditableContainerBox
10051051
}
10061052
return math.max(dy, 0);
10071053
}
1054+
1055+
@override
1056+
Rect getLocalRectForCaret(TextPosition position) {
1057+
final targetChild = childAtPosition(position);
1058+
final localPosition = targetChild.globalToLocalPosition(position);
1059+
1060+
final childLocalRect = targetChild.getLocalRectForCaret(localPosition);
1061+
1062+
final boxParentData = targetChild.parentData as BoxParentData;
1063+
return childLocalRect.shift(Offset(0, boxParentData.offset.dy));
1064+
}
10081065
}
10091066

10101067
class EditableContainerParentData

lib/src/widgets/raw_editor.dart

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ class RawEditorState extends EditorState
115115
EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay;
116116
EditorTextSelectionOverlay? _selectionOverlay;
117117

118-
ScrollController? _scrollController;
118+
@override
119+
ScrollController get scrollController => _scrollController;
120+
late ScrollController _scrollController;
119121

120122
late CursorCont _cursorCont;
121123

@@ -323,7 +325,7 @@ class RawEditorState extends EditorState
323325
});
324326

325327
_scrollController = widget.scrollController;
326-
_scrollController!.addListener(_updateSelectionOverlayForScroll);
328+
_scrollController.addListener(_updateSelectionOverlayForScroll);
327329

328330
_cursorCont = CursorCont(
329331
show: ValueNotifier<bool>(widget.showCursor),
@@ -392,9 +394,9 @@ class RawEditorState extends EditorState
392394
}
393395

394396
if (widget.scrollController != _scrollController) {
395-
_scrollController!.removeListener(_updateSelectionOverlayForScroll);
397+
_scrollController.removeListener(_updateSelectionOverlayForScroll);
396398
_scrollController = widget.scrollController;
397-
_scrollController!.addListener(_updateSelectionOverlayForScroll);
399+
_scrollController.addListener(_updateSelectionOverlayForScroll);
398400
}
399401

400402
if (widget.focusNode != oldWidget.focusNode) {
@@ -570,16 +572,16 @@ class RawEditorState extends EditorState
570572
final viewport = RenderAbstractViewport.of(renderEditor);
571573
final editorOffset =
572574
renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport);
573-
final offsetInViewport = _scrollController!.offset + editorOffset.dy;
575+
final offsetInViewport = _scrollController.offset + editorOffset.dy;
574576

575577
final offset = renderEditor.getOffsetToRevealCursor(
576-
_scrollController!.position.viewportDimension,
577-
_scrollController!.offset,
578+
_scrollController.position.viewportDimension,
579+
_scrollController.offset,
578580
offsetInViewport,
579581
);
580582

581583
if (offset != null) {
582-
_scrollController!.animateTo(
584+
_scrollController.animateTo(
583585
offset,
584586
duration: const Duration(milliseconds: 100),
585587
curve: Curves.fastOutSlowIn,

lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import 'dart:math';
2+
3+
import 'package:flutter/rendering.dart';
14
import 'package:flutter/widgets.dart';
25

36
import '../editor.dart';
@@ -16,7 +19,57 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
1619

1720
@override
1821
void bringIntoView(TextPosition position) {
19-
// TODO: implement bringIntoView
22+
final localRect = getRenderEditor()!.getLocalRectForCaret(position);
23+
final targetOffset = _getOffsetToRevealCaret(localRect, position);
24+
25+
scrollController.jumpTo(targetOffset.offset);
26+
getRenderEditor()!.showOnScreen(rect: targetOffset.rect);
27+
}
28+
29+
// Finds the closest scroll offset to the current scroll offset that fully
30+
// reveals the given caret rect. If the given rect's main axis extent is too
31+
// large to be fully revealed in `renderEditable`, it will be centered along
32+
// the main axis.
33+
//
34+
// If this is a multiline EditableText (which means the Editable can only
35+
// scroll vertically), the given rect's height will first be extended to match
36+
// `renderEditable.preferredLineHeight`, before the target scroll offset is
37+
// calculated.
38+
RevealedOffset _getOffsetToRevealCaret(Rect rect, TextPosition position) {
39+
if (!scrollController.position.allowImplicitScrolling) {
40+
return RevealedOffset(offset: scrollController.offset, rect: rect);
41+
}
42+
43+
final editableSize = getRenderEditor()!.size;
44+
final double additionalOffset;
45+
final Offset unitOffset;
46+
47+
// The caret is vertically centered within the line. Expand the caret's
48+
// height so that it spans the line because we're going to ensure that the
49+
// entire expanded caret is scrolled into view.
50+
final expandedRect = Rect.fromCenter(
51+
center: rect.center,
52+
width: rect.width,
53+
height:
54+
max(rect.height, getRenderEditor()!.preferredLineHeight(position)),
55+
);
56+
57+
additionalOffset = expandedRect.height >= editableSize.height
58+
? editableSize.height / 2 - expandedRect.center.dy
59+
: 0.0
60+
.clamp(expandedRect.bottom - editableSize.height, expandedRect.top);
61+
unitOffset = const Offset(0, 1);
62+
63+
// No overscrolling when encountering tall fonts/scripts that extend past
64+
// the ascent.
65+
final targetOffset = (additionalOffset + scrollController.offset).clamp(
66+
scrollController.position.minScrollExtent,
67+
scrollController.position.maxScrollExtent,
68+
);
69+
70+
final offsetDelta = scrollController.offset - targetOffset;
71+
return RevealedOffset(
72+
rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
2073
}
2174

2275
@override

lib/src/widgets/text_block.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,27 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
527527
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
528528
return defaultHitTestChildren(result, position: position);
529529
}
530+
531+
@override
532+
Rect getLocalRectForCaret(TextPosition position) {
533+
final child = childAtPosition(position);
534+
final localPosition = TextPosition(
535+
offset: position.offset - child.getContainer().offset,
536+
affinity: position.affinity,
537+
);
538+
final parentData = child.parentData as BoxParentData;
539+
return child.getLocalRectForCaret(localPosition).shift(parentData.offset);
540+
}
541+
542+
@override
543+
TextPosition globalToLocalPosition(TextPosition position) {
544+
assert(getContainer().containsOffset(position.offset),
545+
'The provided text position is not in the current node');
546+
return TextPosition(
547+
offset: position.offset - getContainer().documentOffset,
548+
affinity: position.affinity,
549+
);
550+
}
530551
}
531552

532553
class _EditableBlock extends MultiChildRenderObjectWidget {

0 commit comments

Comments
 (0)