Skip to content

Commit f60e54b

Browse files
Fix SelectionArea select-word edge cases (#136920)
This change fixes issues with screen order comparison logic when rects are encompassed within each other. This was causing issues when trying to select text that includes inline `WidgetSpan`s inside of a `SelectionArea`. * Adds `boundingBoxes` to `Selectable` for a more precise hit testing region. Fixes #132821 Fixes updating selection edge by word boundary when widget spans are involved. Fixes crash when sending select word selection event to an unselectable element.
1 parent 67edaef commit f60e54b

File tree

8 files changed

+369
-58
lines changed

8 files changed

+369
-58
lines changed

examples/api/lib/material/selectable_region/selectable_region.0.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
115115

116116
// Selectable APIs.
117117

118+
@override
119+
List<Rect> get boundingBoxes => <Rect>[paintBounds];
120+
118121
// Adjust this value to enlarge or shrink the selection highlight.
119122
static const double _padding = 10.0;
120123
Rect _getSelectionHighlightRect() {

packages/flutter/lib/src/rendering/paragraph.dart

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,13 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
409409
if (end == -1) {
410410
end = plainText.length;
411411
}
412-
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end), fullText: plainText));
412+
result.add(
413+
_SelectableFragment(
414+
paragraph: this,
415+
range: TextRange(start: start, end: end),
416+
fullText: plainText,
417+
),
418+
);
413419
start = end;
414420
}
415421
start += 1;
@@ -1314,7 +1320,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
13141320
/// [PlaceholderSpan]. The [RenderParagraph] splits itself on [PlaceholderSpan]
13151321
/// to create multiple `_SelectableFragment`s so that they can be selected
13161322
/// separately.
1317-
class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutMetrics {
1323+
class _SelectableFragment with Selectable, Diagnosticable, ChangeNotifier implements TextLayoutMetrics {
13181324
_SelectableFragment({
13191325
required this.paragraph,
13201326
required this.fullText,
@@ -1366,7 +1372,6 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
13661372
? startOffsetInParagraphCoordinates
13671373
: paragraph._getOffsetForPosition(TextPosition(offset: selectionEnd));
13681374
final bool flipHandles = isReversed != (TextDirection.rtl == paragraph.textDirection);
1369-
final Matrix4 paragraphToFragmentTransform = getTransformToParagraph()..invert();
13701375
final TextSelection selection = TextSelection(
13711376
baseOffset: selectionStart,
13721377
extentOffset: selectionEnd,
@@ -1377,12 +1382,12 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
13771382
}
13781383
return SelectionGeometry(
13791384
startSelectionPoint: SelectionPoint(
1380-
localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, startOffsetInParagraphCoordinates),
1385+
localPosition: startOffsetInParagraphCoordinates,
13811386
lineHeight: paragraph._textPainter.preferredLineHeight,
13821387
handleType: flipHandles ? TextSelectionHandleType.right : TextSelectionHandleType.left
13831388
),
13841389
endSelectionPoint: SelectionPoint(
1385-
localPosition: MatrixUtils.transformPoint(paragraphToFragmentTransform, endOffsetInParagraphCoordinates),
1390+
localPosition: endOffsetInParagraphCoordinates,
13861391
lineHeight: paragraph._textPainter.preferredLineHeight,
13871392
handleType: flipHandles ? TextSelectionHandleType.left : TextSelectionHandleType.right,
13881393
),
@@ -1665,7 +1670,16 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
16651670
// we do not need to look up the word boundary for that position. This is to
16661671
// maintain a selectables selection collapsed at 0 when the local position is
16671672
// not located inside its rect.
1668-
final _WordBoundaryRecord? wordBoundary = !_rect.contains(localPosition) ? null : _getWordBoundaryAtPosition(position);
1673+
_WordBoundaryRecord? wordBoundary = _rect.contains(localPosition) ? _getWordBoundaryAtPosition(position) : null;
1674+
if (wordBoundary != null
1675+
&& (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start
1676+
|| wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end)) {
1677+
// When the position is located at a placeholder inside of the text, then we may compute
1678+
// a word boundary that does not belong to the current selectable fragment. In this case
1679+
// we should invalidate the word boundary so that it is not taken into account when
1680+
// computing the target position.
1681+
wordBoundary = null;
1682+
}
16691683
final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd));
16701684

16711685
_setSelectionPosition(targetPosition, isEnd: isEnd);
@@ -1717,23 +1731,26 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
17171731
}
17181732

17191733
SelectionResult _handleSelectWord(Offset globalPosition) {
1720-
_selectableContainsOriginWord = true;
1721-
17221734
final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition));
17231735
if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) {
17241736
return SelectionResult.end;
17251737
}
17261738
final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position);
1727-
if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset < range.start) {
1739+
// This fragment may not contain the word, decide what direction the target
1740+
// fragment is located in. Because fragments are separated by placeholder
1741+
// spans, we also check if the beginning or end of the word is touching
1742+
// either edge of this fragment.
1743+
if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset <= range.start) {
17281744
return SelectionResult.previous;
1729-
} else if (wordBoundary.wordStart.offset > range.end && wordBoundary.wordEnd.offset > range.end) {
1745+
} else if (wordBoundary.wordStart.offset >= range.end && wordBoundary.wordEnd.offset > range.end) {
17301746
return SelectionResult.next;
17311747
}
17321748
// Fragments are separated by placeholder span, the word boundary shouldn't
17331749
// expand across fragments.
17341750
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
17351751
_textSelectionStart = wordBoundary.wordStart;
17361752
_textSelectionEnd = wordBoundary.wordEnd;
1753+
_selectableContainsOriginWord = true;
17371754
return SelectionResult.end;
17381755
}
17391756

@@ -1957,13 +1974,9 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
19571974
}
19581975
}
19591976

1960-
Matrix4 getTransformToParagraph() {
1961-
return Matrix4.translationValues(_rect.left, _rect.top, 0.0);
1962-
}
1963-
19641977
@override
19651978
Matrix4 getTransformTo(RenderObject? ancestor) {
1966-
return getTransformToParagraph()..multiply(paragraph.getTransformTo(ancestor));
1979+
return paragraph.getTransformTo(ancestor);
19671980
}
19681981

19691982
@override
@@ -1982,6 +1995,28 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
19821995
}
19831996
}
19841997

1998+
List<Rect>? _cachedBoundingBoxes;
1999+
@override
2000+
List<Rect> get boundingBoxes {
2001+
if (_cachedBoundingBoxes == null) {
2002+
final List<TextBox> boxes = paragraph.getBoxesForSelection(
2003+
TextSelection(baseOffset: range.start, extentOffset: range.end),
2004+
);
2005+
if (boxes.isNotEmpty) {
2006+
_cachedBoundingBoxes = <Rect>[];
2007+
for (final TextBox textBox in boxes) {
2008+
_cachedBoundingBoxes!.add(textBox.toRect());
2009+
}
2010+
} else {
2011+
final Offset offset = paragraph._getOffsetForPosition(TextPosition(offset: range.start));
2012+
final Rect rect = Rect.fromPoints(offset, offset.translate(0, - paragraph._textPainter.preferredLineHeight));
2013+
_cachedBoundingBoxes = <Rect>[rect];
2014+
}
2015+
}
2016+
return _cachedBoundingBoxes!;
2017+
}
2018+
2019+
Rect? _cachedRect;
19852020
Rect get _rect {
19862021
if (_cachedRect == null) {
19872022
final List<TextBox> boxes = paragraph.getBoxesForSelection(
@@ -2000,7 +2035,6 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
20002035
}
20012036
return _cachedRect!;
20022037
}
2003-
Rect? _cachedRect;
20042038

20052039
void didChangeParagraphLayout() {
20062040
_cachedRect = null;
@@ -2028,12 +2062,11 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
20282062
textBox.toRect().shift(offset), selectionPaint);
20292063
}
20302064
}
2031-
final Matrix4 transform = getTransformToParagraph();
20322065
if (_startHandleLayerLink != null && value.startSelectionPoint != null) {
20332066
context.pushLayer(
20342067
LeaderLayer(
20352068
link: _startHandleLayerLink!,
2036-
offset: offset + MatrixUtils.transformPoint(transform, value.startSelectionPoint!.localPosition),
2069+
offset: offset + value.startSelectionPoint!.localPosition,
20372070
),
20382071
(PaintingContext context, Offset offset) { },
20392072
Offset.zero,
@@ -2043,7 +2076,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
20432076
context.pushLayer(
20442077
LeaderLayer(
20452078
link: _endHandleLayerLink!,
2046-
offset: offset + MatrixUtils.transformPoint(transform, value.endSelectionPoint!.localPosition),
2079+
offset: offset + value.endSelectionPoint!.localPosition,
20472080
),
20482081
(PaintingContext context, Offset offset) { },
20492082
Offset.zero,
@@ -2071,4 +2104,12 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
20712104

20722105
@override
20732106
TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position);
2107+
2108+
@override
2109+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
2110+
super.debugFillProperties(properties);
2111+
properties.add(DiagnosticsProperty<String>('textInsideRange', range.textInside(fullText)));
2112+
properties.add(DiagnosticsProperty<TextRange>('range', range));
2113+
properties.add(DiagnosticsProperty<String>('fullText', fullText));
2114+
}
20742115
}

packages/flutter/lib/src/rendering/selection.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ mixin Selectable implements SelectionHandler {
142142
/// The size of this [Selectable].
143143
Size get size;
144144

145+
/// A list of [Rect]s that represent the bounding box of this [Selectable]
146+
/// in local coordinates.
147+
List<Rect> get boundingBoxes;
148+
145149
/// Disposes resources held by the mixer.
146150
void dispose();
147151
}

packages/flutter/lib/src/widgets/selectable_region.dart

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,6 +1817,14 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
18171817
_updateHandleLayersAndOwners();
18181818
}
18191819

1820+
Rect _getBoundingBox(Selectable selectable) {
1821+
Rect result = selectable.boundingBoxes.first;
1822+
for (int index = 1; index < selectable.boundingBoxes.length; index += 1) {
1823+
result = result.expandToInclude(selectable.boundingBoxes[index]);
1824+
}
1825+
return result;
1826+
}
1827+
18201828
/// The compare function this delegate used for determining the selection
18211829
/// order of the selectables.
18221830
///
@@ -1827,11 +1835,11 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
18271835
int _compareScreenOrder(Selectable a, Selectable b) {
18281836
final Rect rectA = MatrixUtils.transformRect(
18291837
a.getTransformTo(null),
1830-
Rect.fromLTWH(0, 0, a.size.width, a.size.height),
1838+
_getBoundingBox(a),
18311839
);
18321840
final Rect rectB = MatrixUtils.transformRect(
18331841
b.getTransformTo(null),
1834-
Rect.fromLTWH(0, 0, b.size.width, b.size.height),
1842+
_getBoundingBox(b),
18351843
);
18361844
final int result = _compareVertically(rectA, rectB);
18371845
if (result != 0) {
@@ -1846,6 +1854,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
18461854
/// Returns positive if a is lower, negative if a is higher, 0 if their
18471855
/// order can't be determine solely by their vertical position.
18481856
static int _compareVertically(Rect a, Rect b) {
1857+
// The rectangles overlap so defer to horizontal comparison.
18491858
if ((a.top - b.top < _kSelectableVerticalComparingThreshold && a.bottom - b.bottom > - _kSelectableVerticalComparingThreshold) ||
18501859
(b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > - _kSelectableVerticalComparingThreshold)) {
18511860
return 0;
@@ -1863,19 +1872,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
18631872
static int _compareHorizontally(Rect a, Rect b) {
18641873
// a encloses b.
18651874
if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) {
1866-
// b ends before a.
1867-
if (a.right - b.right > precisionErrorTolerance) {
1868-
return 1;
1869-
}
18701875
return -1;
18711876
}
1872-
18731877
// b encloses a.
18741878
if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) {
1875-
// a ends before b.
1876-
if (b.right - a.right > precisionErrorTolerance) {
1877-
return -1;
1878-
}
18791879
return 1;
18801880
}
18811881
if ((a.left - b.left).abs() > precisionErrorTolerance) {
@@ -2140,10 +2140,17 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
21402140
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
21412141
SelectionResult? lastSelectionResult;
21422142
for (int index = 0; index < selectables.length; index += 1) {
2143-
final Rect localRect = Rect.fromLTWH(0, 0, selectables[index].size.width, selectables[index].size.height);
2144-
final Matrix4 transform = selectables[index].getTransformTo(null);
2145-
final Rect globalRect = MatrixUtils.transformRect(transform, localRect);
2146-
if (globalRect.contains(event.globalPosition)) {
2143+
bool globalRectsContainsPosition = false;
2144+
if (selectables[index].boundingBoxes.isNotEmpty) {
2145+
for (final Rect rect in selectables[index].boundingBoxes) {
2146+
final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect);
2147+
if (globalRect.contains(event.globalPosition)) {
2148+
globalRectsContainsPosition = true;
2149+
break;
2150+
}
2151+
}
2152+
}
2153+
if (globalRectsContainsPosition) {
21472154
final SelectionGeometry existingGeometry = selectables[index].value;
21482155
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
21492156
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {

packages/flutter/lib/src/widgets/selection_container.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ class _SelectionContainerState extends State<SelectionContainer> with Selectable
200200
@override
201201
Size get size => (context.findRenderObject()! as RenderBox).size;
202202

203+
@override
204+
List<Rect> get boundingBoxes => <Rect>[(context.findRenderObject()! as RenderBox).paintBounds];
205+
203206
@override
204207
void dispose() {
205208
if (!widget._disabled) {

packages/flutter/test/material/selection_area_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ void main() {
9090
),
9191
);
9292

93-
final TestGesture longpress = await tester.startGesture(const Offset(10, 10));
93+
final TestGesture longpress = await tester.startGesture(tester.getCenter(find.byType(Text)));
9494
addTearDown(longpress.removePointer);
9595
await tester.pump(const Duration(milliseconds: 500));
9696
await longpress.up();

packages/flutter/test/widgets/selectable_region_context_menu_test.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,14 @@ class RenderSelectionSpy extends RenderProxyBox
138138
Size get size => _size;
139139
Size _size = Size.zero;
140140

141+
@override
142+
List<Rect> get boundingBoxes => _boundingBoxes;
143+
final List<Rect> _boundingBoxes = <Rect>[];
144+
141145
@override
142146
Size computeDryLayout(BoxConstraints constraints) {
143147
_size = Size(constraints.maxWidth, constraints.maxHeight);
148+
_boundingBoxes.add(Rect.fromLTWH(0.0, 0.0, constraints.maxWidth, constraints.maxHeight));
144149
return _size;
145150
}
146151

0 commit comments

Comments
 (0)