Skip to content

Commit 80bf355

Browse files
authored
Support keyboard selection in SelectabledRegion (#112584)
* Support keyboard selection in selectable region * fix some comments * addressing comments
1 parent cfb2f15 commit 80bf355

12 files changed

+1893
-117
lines changed

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,80 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
194194
_start = Offset.zero;
195195
_end = Offset.infinite;
196196
break;
197+
case SelectionEventType.granularlyExtendSelection:
198+
result = SelectionResult.end;
199+
final GranularlyExtendSelectionEvent extendSelectionEvent = event as GranularlyExtendSelectionEvent;
200+
// Initialize the offset it there is no ongoing selection.
201+
if (_start == null || _end == null) {
202+
if (extendSelectionEvent.forward) {
203+
_start = _end = Offset.zero;
204+
} else {
205+
_start = _end = Offset.infinite;
206+
}
207+
}
208+
// Move the corresponding selection edge.
209+
final Offset newOffset = extendSelectionEvent.forward ? Offset.infinite : Offset.zero;
210+
if (extendSelectionEvent.isEnd) {
211+
if (newOffset == _end) {
212+
result = extendSelectionEvent.forward ? SelectionResult.next : SelectionResult.previous;
213+
}
214+
_end = newOffset;
215+
} else {
216+
if (newOffset == _start) {
217+
result = extendSelectionEvent.forward ? SelectionResult.next : SelectionResult.previous;
218+
}
219+
_start = newOffset;
220+
}
221+
break;
222+
case SelectionEventType.directionallyExtendSelection:
223+
result = SelectionResult.end;
224+
final DirectionallyExtendSelectionEvent extendSelectionEvent = event as DirectionallyExtendSelectionEvent;
225+
// Convert to local coordinates.
226+
final double horizontalBaseLine = globalToLocal(Offset(event.dx, 0)).dx;
227+
final Offset newOffset;
228+
final bool forward;
229+
switch(extendSelectionEvent.direction) {
230+
case SelectionExtendDirection.backward:
231+
case SelectionExtendDirection.previousLine:
232+
forward = false;
233+
// Initialize the offset it there is no ongoing selection.
234+
if (_start == null || _end == null) {
235+
_start = _end = Offset.infinite;
236+
}
237+
// Move the corresponding selection edge.
238+
if (extendSelectionEvent.direction == SelectionExtendDirection.previousLine || horizontalBaseLine < 0) {
239+
newOffset = Offset.zero;
240+
} else {
241+
newOffset = Offset.infinite;
242+
}
243+
break;
244+
case SelectionExtendDirection.nextLine:
245+
case SelectionExtendDirection.forward:
246+
forward = true;
247+
// Initialize the offset it there is no ongoing selection.
248+
if (_start == null || _end == null) {
249+
_start = _end = Offset.zero;
250+
}
251+
// Move the corresponding selection edge.
252+
if (extendSelectionEvent.direction == SelectionExtendDirection.nextLine || horizontalBaseLine > size.width) {
253+
newOffset = Offset.infinite;
254+
} else {
255+
newOffset = Offset.zero;
256+
}
257+
break;
258+
}
259+
if (extendSelectionEvent.isEnd) {
260+
if (newOffset == _end) {
261+
result = forward ? SelectionResult.next : SelectionResult.previous;
262+
}
263+
_end = newOffset;
264+
} else {
265+
if (newOffset == _start) {
266+
result = forward ? SelectionResult.next : SelectionResult.previous;
267+
}
268+
_start = newOffset;
269+
}
270+
break;
197271
}
198272
_updateGeometry();
199273
return result;

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

Lines changed: 223 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44

55
import 'dart:collection';
66
import 'dart:math' as math;
7-
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior;
7+
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle, Gradient, LineMetrics, PlaceholderAlignment, Shader, TextBox, TextHeightBehavior;
88

99
import 'package:flutter/foundation.dart';
1010
import 'package:flutter/gestures.dart';
1111
import 'package:flutter/scheduler.dart';
1212
import 'package:flutter/semantics.dart';
13+
import 'package:flutter/services.dart';
1314

1415
import 'box.dart';
1516
import 'debug.dart';
17+
import 'editable.dart';
1618
import 'layer.dart';
1719
import 'object.dart';
1820
import 'selection.dart';
@@ -151,11 +153,11 @@ class RenderParagraph extends RenderBox
151153
_cachedCombinedSemanticsInfos = null;
152154
_extractPlaceholderSpans(value);
153155
markNeedsLayout();
156+
_removeSelectionRegistrarSubscription();
157+
_disposeSelectableFragments();
158+
_updateSelectionRegistrarSubscription();
154159
break;
155160
}
156-
_removeSelectionRegistrarSubscription();
157-
_disposeSelectableFragments();
158-
_updateSelectionRegistrarSubscription();
159161
}
160162

161163
/// The ongoing selections in this paragraph.
@@ -226,7 +228,7 @@ class RenderParagraph extends RenderBox
226228
if (end == -1) {
227229
end = plainText.length;
228230
}
229-
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end)));
231+
result.add(_SelectableFragment(paragraph: this, range: TextRange(start: start, end: end), fullText: plainText));
230232
start = end;
231233
}
232234
start += 1;
@@ -439,6 +441,10 @@ class RenderParagraph extends RenderBox
439441
return getOffsetForCaret(position, Rect.zero) + Offset(0, getFullHeightForCaret(position) ?? 0.0);
440442
}
441443

444+
List<ui.LineMetrics> _computeLineMetrics() {
445+
return _textPainter.computeLineMetrics();
446+
}
447+
442448
@override
443449
double computeMinIntrinsicWidth(double height) {
444450
if (!_canComputeIntrinsics()) {
@@ -1027,6 +1033,28 @@ class RenderParagraph extends RenderBox
10271033
return _textPainter.getWordBoundary(position);
10281034
}
10291035

1036+
TextRange _getLineAtOffset(TextPosition position) => _textPainter.getLineBoundary(position);
1037+
1038+
TextPosition _getTextPositionAbove(TextPosition position) {
1039+
// -0.5 of preferredLineHeight points to the middle of the line above.
1040+
final double preferredLineHeight = _textPainter.preferredLineHeight;
1041+
final double verticalOffset = -0.5 * preferredLineHeight;
1042+
return _getTextPositionVertical(position, verticalOffset);
1043+
}
1044+
1045+
TextPosition _getTextPositionBelow(TextPosition position) {
1046+
// 1.5 of preferredLineHeight points to the middle of the line below.
1047+
final double preferredLineHeight = _textPainter.preferredLineHeight;
1048+
final double verticalOffset = 1.5 * preferredLineHeight;
1049+
return _getTextPositionVertical(position, verticalOffset);
1050+
}
1051+
1052+
TextPosition _getTextPositionVertical(TextPosition position, double verticalOffset) {
1053+
final Offset caretOffset = _textPainter.getOffsetForCaret(position, Rect.zero);
1054+
final Offset caretOffsetTranslated = caretOffset.translate(0.0, verticalOffset);
1055+
return _textPainter.getPositionForOffset(caretOffsetTranslated);
1056+
}
1057+
10301058
/// Returns the size of the text as laid out.
10311059
///
10321060
/// This can differ from [size] if the text overflowed or if the [constraints]
@@ -1271,16 +1299,18 @@ class RenderParagraph extends RenderBox
12711299
/// [PlaceHolderSpan]. The [RenderParagraph] splits itself on [PlaceHolderSpan]
12721300
/// to create multiple `_SelectableFragment`s so that they can be selected
12731301
/// separately.
1274-
class _SelectableFragment with Selectable, ChangeNotifier {
1302+
class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutMetrics {
12751303
_SelectableFragment({
12761304
required this.paragraph,
1305+
required this.fullText,
12771306
required this.range,
12781307
}) : assert(range.isValid && !range.isCollapsed && range.isNormalized) {
12791308
_selectionGeometry = _getSelectionGeometry();
12801309
}
12811310

12821311
final TextRange range;
12831312
final RenderParagraph paragraph;
1313+
final String fullText;
12841314

12851315
TextPosition? _textSelectionStart;
12861316
TextPosition? _textSelectionEnd;
@@ -1356,6 +1386,22 @@ class _SelectableFragment with Selectable, ChangeNotifier {
13561386
final SelectWordSelectionEvent selectWord = event as SelectWordSelectionEvent;
13571387
result = _handleSelectWord(selectWord.globalPosition);
13581388
break;
1389+
case SelectionEventType.granularlyExtendSelection:
1390+
final GranularlyExtendSelectionEvent granularlyExtendSelection = event as GranularlyExtendSelectionEvent;
1391+
result = _handleGranularlyExtendSelection(
1392+
granularlyExtendSelection.forward,
1393+
granularlyExtendSelection.isEnd,
1394+
granularlyExtendSelection.granularity,
1395+
);
1396+
break;
1397+
case SelectionEventType.directionallyExtendSelection:
1398+
final DirectionallyExtendSelectionEvent directionallyExtendSelection = event as DirectionallyExtendSelectionEvent;
1399+
result = _handleDirectionallyExtendSelection(
1400+
directionallyExtendSelection.dx,
1401+
directionallyExtendSelection.isEnd,
1402+
directionallyExtendSelection.direction,
1403+
);
1404+
break;
13591405
}
13601406

13611407
if (existingSelectionStart != _textSelectionStart ||
@@ -1373,7 +1419,7 @@ class _SelectableFragment with Selectable, ChangeNotifier {
13731419
final int start = math.min(_textSelectionStart!.offset, _textSelectionEnd!.offset);
13741420
final int end = math.max(_textSelectionStart!.offset, _textSelectionEnd!.offset);
13751421
return SelectedContent(
1376-
plainText: paragraph.text.toPlainText(includeSemanticsLabels: false).substring(start, end),
1422+
plainText: fullText.substring(start, end),
13771423
);
13781424
}
13791425

@@ -1466,6 +1512,155 @@ class _SelectableFragment with Selectable, ChangeNotifier {
14661512
return SelectionResult.end;
14671513
}
14681514

1515+
SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) {
1516+
final Matrix4 transform = paragraph.getTransformTo(null);
1517+
if (transform.invert() == 0.0) {
1518+
switch(movement) {
1519+
case SelectionExtendDirection.previousLine:
1520+
case SelectionExtendDirection.backward:
1521+
return SelectionResult.previous;
1522+
case SelectionExtendDirection.nextLine:
1523+
case SelectionExtendDirection.forward:
1524+
return SelectionResult.next;
1525+
}
1526+
}
1527+
final double baselineInParagraphCoordinates = MatrixUtils.transformPoint(transform, Offset(horizontalBaseline, 0)).dx;
1528+
assert(!baselineInParagraphCoordinates.isNaN);
1529+
final TextPosition newPosition;
1530+
final SelectionResult result;
1531+
switch(movement) {
1532+
case SelectionExtendDirection.previousLine:
1533+
case SelectionExtendDirection.nextLine:
1534+
assert(_textSelectionEnd != null && _textSelectionStart != null);
1535+
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
1536+
final MapEntry<TextPosition, SelectionResult> moveResult = _handleVerticalMovement(
1537+
targetedEdge,
1538+
horizontalBaselineInParagraphCoordinates: baselineInParagraphCoordinates,
1539+
below: movement == SelectionExtendDirection.nextLine,
1540+
);
1541+
newPosition = moveResult.key;
1542+
result = moveResult.value;
1543+
break;
1544+
case SelectionExtendDirection.forward:
1545+
case SelectionExtendDirection.backward:
1546+
_textSelectionEnd ??= movement == SelectionExtendDirection.forward
1547+
? TextPosition(offset: range.start)
1548+
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
1549+
_textSelectionStart ??= _textSelectionEnd;
1550+
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
1551+
final Offset edgeOffsetInParagraphCoordinates = paragraph._getOffsetForPosition(targetedEdge);
1552+
final Offset baselineOffsetInParagraphCoordinates = Offset(
1553+
baselineInParagraphCoordinates,
1554+
// Use half of line height to point to the middle of the line.
1555+
edgeOffsetInParagraphCoordinates.dy - paragraph._textPainter.preferredLineHeight / 2,
1556+
);
1557+
newPosition = paragraph.getPositionForOffset(baselineOffsetInParagraphCoordinates);
1558+
result = SelectionResult.end;
1559+
break;
1560+
}
1561+
if (isExtent) {
1562+
_textSelectionEnd = newPosition;
1563+
} else {
1564+
_textSelectionStart = newPosition;
1565+
}
1566+
return result;
1567+
}
1568+
1569+
SelectionResult _handleGranularlyExtendSelection(bool forward, bool isExtent, TextGranularity granularity) {
1570+
_textSelectionEnd ??= forward
1571+
? TextPosition(offset: range.start)
1572+
: TextPosition(offset: range.end, affinity: TextAffinity.upstream);
1573+
_textSelectionStart ??= _textSelectionEnd;
1574+
final TextPosition targetedEdge = isExtent ? _textSelectionEnd! : _textSelectionStart!;
1575+
if (forward && (targetedEdge.offset == range.end)) {
1576+
return SelectionResult.next;
1577+
}
1578+
if (!forward && (targetedEdge.offset == range.start)) {
1579+
return SelectionResult.previous;
1580+
}
1581+
final SelectionResult result;
1582+
final TextPosition newPosition;
1583+
switch (granularity) {
1584+
case TextGranularity.character:
1585+
final String text = range.textInside(fullText);
1586+
newPosition = _getNextPosition(CharacterBoundary(text), targetedEdge, forward);
1587+
result = SelectionResult.end;
1588+
break;
1589+
case TextGranularity.word:
1590+
final String text = range.textInside(fullText);
1591+
newPosition = _getNextPosition(WhitespaceBoundary(text) + WordBoundary(this), targetedEdge, forward);
1592+
result = SelectionResult.end;
1593+
break;
1594+
case TextGranularity.line:
1595+
newPosition = _getNextPosition(LineBreak(this), targetedEdge, forward);
1596+
result = SelectionResult.end;
1597+
break;
1598+
case TextGranularity.document:
1599+
final String text = range.textInside(fullText);
1600+
newPosition = _getNextPosition(DocumentBoundary(text), targetedEdge, forward);
1601+
if (forward && newPosition.offset == range.end) {
1602+
result = SelectionResult.next;
1603+
} else if (!forward && newPosition.offset == range.start) {
1604+
result = SelectionResult.previous;
1605+
} else {
1606+
result = SelectionResult.end;
1607+
}
1608+
break;
1609+
}
1610+
1611+
if (isExtent) {
1612+
_textSelectionEnd = newPosition;
1613+
} else {
1614+
_textSelectionStart = newPosition;
1615+
}
1616+
return result;
1617+
}
1618+
1619+
TextPosition _getNextPosition(TextBoundary boundary, TextPosition position, bool forward) {
1620+
if (forward) {
1621+
return _clampTextPosition(
1622+
(PushTextPosition.forward + boundary).getTrailingTextBoundaryAt(position)
1623+
);
1624+
}
1625+
return _clampTextPosition(
1626+
(PushTextPosition.backward + boundary).getLeadingTextBoundaryAt(position),
1627+
);
1628+
}
1629+
1630+
MapEntry<TextPosition, SelectionResult> _handleVerticalMovement(TextPosition position, {required double horizontalBaselineInParagraphCoordinates, required bool below}) {
1631+
final List<ui.LineMetrics> lines = paragraph._computeLineMetrics();
1632+
final Offset offset = paragraph.getOffsetForCaret(position, Rect.zero);
1633+
int currentLine = lines.length - 1;
1634+
for (final ui.LineMetrics lineMetrics in lines) {
1635+
if (lineMetrics.baseline > offset.dy) {
1636+
currentLine = lineMetrics.lineNumber;
1637+
break;
1638+
}
1639+
}
1640+
final TextPosition newPosition;
1641+
if (below && currentLine == lines.length - 1) {
1642+
newPosition = TextPosition(offset: range.end, affinity: TextAffinity.upstream);
1643+
} else if (!below && currentLine == 0) {
1644+
newPosition = TextPosition(offset: range.start);
1645+
} else {
1646+
final int newLine = below ? currentLine + 1 : currentLine - 1;
1647+
newPosition = _clampTextPosition(
1648+
paragraph.getPositionForOffset(Offset(horizontalBaselineInParagraphCoordinates, lines[newLine].baseline))
1649+
);
1650+
}
1651+
final SelectionResult result;
1652+
if (newPosition.offset == range.start) {
1653+
result = SelectionResult.previous;
1654+
} else if (newPosition.offset == range.end) {
1655+
result = SelectionResult.next;
1656+
} else {
1657+
result = SelectionResult.end;
1658+
}
1659+
assert(result != SelectionResult.next || below);
1660+
assert(result != SelectionResult.previous || !below);
1661+
return MapEntry<TextPosition, SelectionResult>(newPosition, result);
1662+
}
1663+
14691664
/// Whether the given text position is contained in current selection
14701665
/// range.
14711666
///
@@ -1596,4 +1791,25 @@ class _SelectableFragment with Selectable, ChangeNotifier {
15961791
);
15971792
}
15981793
}
1794+
1795+
@override
1796+
TextSelection getLineAtOffset(TextPosition position) {
1797+
final TextRange line = paragraph._getLineAtOffset(position);
1798+
final int start = line.start.clamp(range.start, range.end); // ignore_clamp_double_lint
1799+
final int end = line.end.clamp(range.start, range.end); // ignore_clamp_double_lint
1800+
return TextSelection(baseOffset: start, extentOffset: end);
1801+
}
1802+
1803+
@override
1804+
TextPosition getTextPositionAbove(TextPosition position) {
1805+
return _clampTextPosition(paragraph._getTextPositionAbove(position));
1806+
}
1807+
1808+
@override
1809+
TextPosition getTextPositionBelow(TextPosition position) {
1810+
return _clampTextPosition(paragraph._getTextPositionBelow(position));
1811+
}
1812+
1813+
@override
1814+
TextRange getWordBoundary(TextPosition position) => paragraph.getWordBoundary(position);
15991815
}

0 commit comments

Comments
 (0)