Skip to content

Commit 21ad712

Browse files
Implement SelectionArea single click/tap gestures (#132682)
This change collapses the selection at the clicked/tapped location on single click down for desktop platforms, and on single click/tap up for mobile platforms to match native. This is a change from how `SelectionArea` previously worked. Before this change a single click down would clear the selection. From observing a native browser it looks like when tapping on static text the selection is not cleared but collapsed. A user can still attain the selection from static text using the `window.getSelection` API. https://jsfiddle.net/juepasn3/11/ You can try this demo out here to observe this behavior yourself. When clicking on static text the selection will change. This change also allows `Paragraph.selections` to return selections that are collapsed. This for testing purposes to confirm where the selection has been collapsed. Partially fixes: #129583
1 parent 80fb7bd commit 21ad712

File tree

7 files changed

+375
-99
lines changed

7 files changed

+375
-99
lines changed

examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ void main() {
2424

2525
// Right clicking the Text in the SelectionArea shows the custom context
2626
// menu.
27+
final TestGesture primaryMouseButtonGesture = await tester.createGesture(
28+
kind: PointerDeviceKind.mouse,
29+
);
2730
final TestGesture gesture = await tester.startGesture(
2831
tester.getCenter(find.text(example.text)),
2932
kind: PointerDeviceKind.mouse,
@@ -37,7 +40,9 @@ void main() {
3740
expect(find.text('Print'), findsOneWidget);
3841

3942
// Tap to dismiss.
40-
await tester.tapAt(tester.getCenter(find.byType(Scaffold)));
43+
await primaryMouseButtonGesture.down(tester.getCenter(find.byType(Scaffold)));
44+
await tester.pump();
45+
await primaryMouseButtonGesture.up();
4146
await tester.pumpAndSettle();
4247

4348
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ mixin RenderInlineChildrenContainerDefaults on RenderBox, ContainerRenderObjectM
132132
ui.PlaceholderAlignment.belowBaseline ||
133133
ui.PlaceholderAlignment.bottom ||
134134
ui.PlaceholderAlignment.middle ||
135-
ui.PlaceholderAlignment.top => null,
135+
ui.PlaceholderAlignment.top => null,
136136
ui.PlaceholderAlignment.baseline => child.getDistanceToBaseline(span.baseline!),
137137
},
138138
);
@@ -351,8 +351,7 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
351351
final List<TextSelection> results = <TextSelection>[];
352352
for (final _SelectableFragment fragment in _lastSelectableFragments!) {
353353
if (fragment._textSelectionStart != null &&
354-
fragment._textSelectionEnd != null &&
355-
fragment._textSelectionStart!.offset != fragment._textSelectionEnd!.offset) {
354+
fragment._textSelectionEnd != null) {
356355
results.add(
357356
TextSelection(
358357
baseOffset: fragment._textSelectionStart!.offset,
@@ -1309,9 +1308,9 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin<RenderBo
13091308

13101309
/// A continuous, selectable piece of paragraph.
13111310
///
1312-
/// Since the selections in [PlaceHolderSpan] are handled independently in its
1311+
/// Since the selections in [PlaceholderSpan] are handled independently in its
13131312
/// subtree, a selection in [RenderParagraph] can't continue across a
1314-
/// [PlaceHolderSpan]. The [RenderParagraph] splits itself on [PlaceHolderSpan]
1313+
/// [PlaceholderSpan]. The [RenderParagraph] splits itself on [PlaceholderSpan]
13151314
/// to create multiple `_SelectableFragment`s so that they can be selected
13161315
/// separately.
13171316
class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutMetrics {
@@ -1720,7 +1719,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
17201719
_selectableContainsOriginWord = true;
17211720

17221721
final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition));
1723-
if (_positionIsWithinCurrentSelection(position)) {
1722+
if (_positionIsWithinCurrentSelection(position) && _textSelectionStart != _textSelectionEnd) {
17241723
return SelectionResult.end;
17251724
}
17261725
final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
670670
if (oldWidget.controller == null) {
671671
// The old controller was null, meaning the fallback cannot be null.
672672
// Dispose of the fallback.
673-
assert(_fallbackScrollController != null);
673+
assert(_fallbackScrollController != null);
674674
assert(widget.controller != null);
675675
_fallbackScrollController!.detach(position);
676676
_fallbackScrollController!.dispose();
@@ -1954,7 +1954,7 @@ class TwoDimensionalScrollableState extends State<TwoDimensionalScrollable> {
19541954
if (oldWidget.horizontalDetails.controller == null) {
19551955
// The old controller was null, meaning the fallback cannot be null.
19561956
// Dispose of the fallback.
1957-
assert(_horizontalFallbackController != null);
1957+
assert(_horizontalFallbackController != null);
19581958
assert(widget.horizontalDetails.controller != null);
19591959
_horizontalFallbackController!.dispose();
19601960
_horizontalFallbackController = null;

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

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
352352
_showToolbar(location: details.globalPosition);
353353
}
354354
} else {
355-
_clearSelection();
355+
hideToolbar();
356+
_collapseSelectionAt(offset: details.globalPosition);
356357
}
357358
};
358359
instance.onSecondaryTapDown = _handleRightClickDown;
@@ -472,6 +473,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
472473
(TapAndPanGestureRecognizer instance) {
473474
instance
474475
..onTapDown = _startNewMouseSelectionGesture
476+
..onTapUp = _handleMouseTapUp
475477
..onDragStart = _handleMouseDragStart
476478
..onDragUpdate = _handleMouseDragUpdate
477479
..onDragEnd = _handleMouseDragEnd
@@ -498,7 +500,17 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
498500
case 1:
499501
widget.focusNode.requestFocus();
500502
hideToolbar();
501-
_clearSelection();
503+
switch (defaultTargetPlatform) {
504+
case TargetPlatform.android:
505+
case TargetPlatform.fuchsia:
506+
case TargetPlatform.iOS:
507+
// On mobile platforms the selection is set on tap up.
508+
break;
509+
case TargetPlatform.macOS:
510+
case TargetPlatform.linux:
511+
case TargetPlatform.windows:
512+
_collapseSelectionAt(offset: details.globalPosition);
513+
}
502514
case 2:
503515
_selectWordAt(offset: details.globalPosition);
504516
}
@@ -528,6 +540,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
528540
_updateSelectedContentIfNeeded();
529541
}
530542

543+
void _handleMouseTapUp(TapDragUpDetails details) {
544+
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
545+
case 1:
546+
switch (defaultTargetPlatform) {
547+
case TargetPlatform.android:
548+
case TargetPlatform.fuchsia:
549+
case TargetPlatform.iOS:
550+
_collapseSelectionAt(offset: details.globalPosition);
551+
case TargetPlatform.macOS:
552+
case TargetPlatform.linux:
553+
case TargetPlatform.windows:
554+
// On desktop platforms the selection is set on tap down.
555+
break;
556+
}
557+
}
558+
_updateSelectedContentIfNeeded();
559+
}
560+
531561
void _updateSelectedContentIfNeeded() {
532562
if (_lastSelectedContent?.plainText != _selectable?.getSelectedContent()?.plainText) {
533563
_lastSelectedContent = _selectable?.getSelectedContent();
@@ -586,8 +616,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
586616
// keep the current selection, if not then collapse it.
587617
final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition);
588618
if (!lastSecondaryTapDownPositionWasOnActiveSelection) {
589-
_selectStartTo(offset: lastSecondaryTapDownPosition!);
590-
_selectEndTo(offset: lastSecondaryTapDownPosition!);
619+
_collapseSelectionAt(offset: lastSecondaryTapDownPosition!);
591620
}
592621
_showHandles();
593622
_showToolbar(location: lastSecondaryTapDownPosition);
@@ -612,8 +641,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
612641
// keep the current selection, if not then collapse it.
613642
final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition);
614643
if (!lastSecondaryTapDownPositionWasOnActiveSelection) {
615-
_selectStartTo(offset: lastSecondaryTapDownPosition!);
616-
_selectEndTo(offset: lastSecondaryTapDownPosition!);
644+
_collapseSelectionAt(offset: lastSecondaryTapDownPosition!);
617645
}
618646
_showHandles();
619647
_showToolbar(location: lastSecondaryTapDownPosition);
@@ -925,8 +953,9 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
925953
/// See also:
926954
/// * [_selectStartTo], which sets or updates selection start edge.
927955
/// * [_finalizeSelection], which stops the `continuous` updates.
928-
/// * [_clearSelection], which clear the ongoing selection.
956+
/// * [_clearSelection], which clears the ongoing selection.
929957
/// * [_selectWordAt], which selects a whole word at the location.
958+
/// * [_collapseSelectionAt], which collapses the selection at the location.
930959
/// * [selectAll], which selects the entire content.
931960
void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
932961
if (!continuous) {
@@ -964,8 +993,9 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
964993
/// See also:
965994
/// * [_selectEndTo], which sets or updates selection end edge.
966995
/// * [_finalizeSelection], which stops the `continuous` updates.
967-
/// * [_clearSelection], which clear the ongoing selection.
996+
/// * [_clearSelection], which clears the ongoing selection.
968997
/// * [_selectWordAt], which selects a whole word at the location.
998+
/// * [_collapseSelectionAt], which collapses the selection at the location.
969999
/// * [selectAll], which selects the entire content.
9701000
void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
9711001
if (!continuous) {
@@ -978,6 +1008,20 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
9781008
}
9791009
}
9801010

1011+
/// Collapses the selection at the given `offset` location.
1012+
///
1013+
/// See also:
1014+
/// * [_selectStartTo], which sets or updates selection start edge.
1015+
/// * [_selectEndTo], which sets or updates selection end edge.
1016+
/// * [_finalizeSelection], which stops the `continuous` updates.
1017+
/// * [_clearSelection], which clears the ongoing selection.
1018+
/// * [_selectWordAt], which selects a whole word at the location.
1019+
/// * [selectAll], which selects the entire content.
1020+
void _collapseSelectionAt({required Offset offset}) {
1021+
_selectStartTo(offset: offset);
1022+
_selectEndTo(offset: offset);
1023+
}
1024+
9811025
/// Selects a whole word at the `offset` location.
9821026
///
9831027
/// If the whole word is already in the current selection, selection won't
@@ -991,7 +1035,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
9911035
/// * [_selectStartTo], which sets or updates selection start edge.
9921036
/// * [_selectEndTo], which sets or updates selection end edge.
9931037
/// * [_finalizeSelection], which stops the `continuous` updates.
994-
/// * [_clearSelection], which clear the ongoing selection.
1038+
/// * [_clearSelection], which clears the ongoing selection.
1039+
/// * [_collapseSelectionAt], which collapses the selection at the location.
9951040
/// * [selectAll], which selects the entire content.
9961041
void _selectWordAt({required Offset offset}) {
9971042
// There may be other selection ongoing.
@@ -1881,7 +1926,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
18811926

18821927
SelectionPoint? startPoint;
18831928
if (startGeometry.startSelectionPoint != null) {
1884-
final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]);
1929+
final Matrix4 startTransform = getTransformFrom(selectables[startIndexWalker]);
18851930
final Offset start = MatrixUtils.transformPoint(startTransform, startGeometry.startSelectionPoint!.localPosition);
18861931
// It can be NaN if it is detached or off-screen.
18871932
if (start.isFinite) {
@@ -1902,7 +1947,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
19021947
}
19031948
SelectionPoint? endPoint;
19041949
if (endGeometry.endSelectionPoint != null) {
1905-
final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]);
1950+
final Matrix4 endTransform = getTransformFrom(selectables[endIndexWalker]);
19061951
final Offset end = MatrixUtils.transformPoint(endTransform, endGeometry.endSelectionPoint!.localPosition);
19071952
// It can be NaN if it is detached or off-screen.
19081953
if (end.isFinite) {
@@ -1986,8 +2031,8 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
19862031
final Rect? drawableArea = hasSize ? Rect
19872032
.fromLTWH(0, 0, containerSize.width, containerSize.height)
19882033
.inflate(_kSelectionHandleDrawableAreaPadding) : null;
1989-
final bool hideStartHandle = value.startSelectionPoint == null || drawableArea == null || !drawableArea.contains(value.startSelectionPoint!.localPosition);
1990-
final bool hideEndHandle = value.endSelectionPoint == null || drawableArea == null|| !drawableArea.contains(value.endSelectionPoint!.localPosition);
2034+
final bool hideStartHandle = value.startSelectionPoint == null || drawableArea == null || !drawableArea.contains(value.startSelectionPoint!.localPosition);
2035+
final bool hideEndHandle = value.endSelectionPoint == null || drawableArea == null|| !drawableArea.contains(value.endSelectionPoint!.localPosition);
19912036
effectiveStartHandle = hideStartHandle ? null : _startHandleLayer;
19922037
effectiveEndHandle = hideEndHandle ? null : _endHandleLayer;
19932038
}
@@ -2047,6 +2092,34 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
20472092
);
20482093
}
20492094

2095+
// Clears the selection on all selectables not in the range of
2096+
// currentSelectionStartIndex..currentSelectionEndIndex.
2097+
//
2098+
// If one of the edges does not exist, then this method will clear the selection
2099+
// in all selectables except the existing edge.
2100+
//
2101+
// If neither of the edges exist this method immediately returns.
2102+
void _flushInactiveSelections() {
2103+
if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) {
2104+
return;
2105+
}
2106+
if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) {
2107+
final int skipIndex = currentSelectionStartIndex == -1 ? currentSelectionEndIndex : currentSelectionStartIndex;
2108+
selectables
2109+
.where((Selectable target) => target != selectables[skipIndex])
2110+
.forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent()));
2111+
return;
2112+
}
2113+
final int skipStart = min(currentSelectionStartIndex, currentSelectionEndIndex);
2114+
final int skipEnd = max(currentSelectionStartIndex, currentSelectionEndIndex);
2115+
for (int index = 0; index < selectables.length; index += 1) {
2116+
if (index >= skipStart && index <= skipEnd) {
2117+
continue;
2118+
}
2119+
dispatchSelectionEventToChild(selectables[index], const ClearSelectionEvent());
2120+
}
2121+
}
2122+
20502123
/// Selects all contents of all selectables.
20512124
@protected
20522125
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
@@ -2290,7 +2363,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
22902363
bool hasFoundEdgeIndex = false;
22912364
SelectionResult? result;
22922365
for (int index = 0; index < selectables.length && !hasFoundEdgeIndex; index += 1) {
2293-
final Selectable child = selectables[index];
2366+
final Selectable child = selectables[index];
22942367
final SelectionResult childResult = dispatchSelectionEventToChild(child, event);
22952368
switch (childResult) {
22962369
case SelectionResult.next:
@@ -2323,6 +2396,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
23232396
} else {
23242397
currentSelectionStartIndex = newIndex;
23252398
}
2399+
_flushInactiveSelections();
23262400
// The result can only be null if the loop went through the entire list
23272401
// without any of the selection returned end or previous. In this case, the
23282402
// caller of this method needs to find the next selectable in their list.
@@ -2345,13 +2419,39 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
23452419
return true;
23462420
}());
23472421
SelectionResult? finalResult;
2348-
int newIndex = isEnd ? currentSelectionEndIndex : currentSelectionStartIndex;
2422+
// Determines if the edge being adjusted is within the current viewport.
2423+
// - If so, we begin the search for the new selection edge position at the
2424+
// currentSelectionEndIndex/currentSelectionStartIndex.
2425+
// - If not, we attempt to locate the new selection edge starting from
2426+
// the opposite end.
2427+
// - If neither edge is in the current viewport, the search for the new
2428+
// selection edge position begins at 0.
2429+
//
2430+
// This can happen when there is a scrollable child and the edge being adjusted
2431+
// has been scrolled out of view.
2432+
final bool isCurrentEdgeWithinViewport = isEnd ? _selectionGeometry.endSelectionPoint != null : _selectionGeometry.startSelectionPoint != null;
2433+
final bool isOppositeEdgeWithinViewport = isEnd ? _selectionGeometry.startSelectionPoint != null : _selectionGeometry.endSelectionPoint != null;
2434+
int newIndex = switch ((isEnd, isCurrentEdgeWithinViewport, isOppositeEdgeWithinViewport)) {
2435+
(true, true, true) => currentSelectionEndIndex,
2436+
(true, true, false) => currentSelectionEndIndex,
2437+
(true, false, true) => currentSelectionStartIndex,
2438+
(true, false, false) => 0,
2439+
(false, true, true) => currentSelectionStartIndex,
2440+
(false, true, false) => currentSelectionStartIndex,
2441+
(false, false, true) => currentSelectionEndIndex,
2442+
(false, false, false) => 0,
2443+
};
23492444
bool? forward;
23502445
late SelectionResult currentSelectableResult;
2351-
// This loop sends the selection event to the
2352-
// currentSelectionEndIndex/currentSelectionStartIndex to determine the
2353-
// direction of the search. If the result is `SelectionResult.next`, this
2354-
// loop look backward. Otherwise, it looks forward.
2446+
// This loop sends the selection event to one of the following to determine
2447+
// the direction of the search.
2448+
// - currentSelectionEndIndex/currentSelectionStartIndex if the current edge
2449+
// is in the current viewport.
2450+
// - The opposite edge index if the current edge is not in the current viewport.
2451+
// - Index 0 if neither edge is in the current viewport.
2452+
//
2453+
// If the result is `SelectionResult.next`, this loop look backward.
2454+
// Otherwise, it looks forward.
23552455
//
23562456
// The terminate condition are:
23572457
// 1. the selectable returns end, pending, none.
@@ -2391,6 +2491,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
23912491
} else {
23922492
currentSelectionStartIndex = newIndex;
23932493
}
2494+
_flushInactiveSelections();
23942495
return finalResult!;
23952496
}
23962497
}

packages/flutter/test/material/selection_area_test.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,14 @@ void main() {
224224

225225
// Backwards selection.
226226
await gesture.down(textOffsetToPosition(paragraph, 3));
227-
await tester.pumpAndSettle();
228-
expect(content, isNull);
227+
await tester.pump();
228+
await gesture.up();
229+
await tester.pumpAndSettle(kDoubleTapTimeout);
230+
expect(content, isNotNull);
231+
expect(content!.plainText, '');
232+
233+
await gesture.down(textOffsetToPosition(paragraph, 3));
234+
await tester.pump();
229235
await gesture.moveTo(textOffsetToPosition(paragraph, 0));
230236
await gesture.up();
231237
await tester.pump();

packages/flutter/test/rendering/paragraph_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -978,7 +978,8 @@ void main() {
978978
granularity: TextGranularity.word,
979979
),
980980
);
981-
expect(paragraph.selections.length, 0); // how []are you
981+
expect(paragraph.selections.length, 1); // how []are you
982+
expect(paragraph.selections[0], const TextSelection.collapsed(offset: 4));
982983

983984
// Equivalent to sending shift + alt + arrow-left.
984985
registrar.selectables[0].dispatchSelectionEvent(

0 commit comments

Comments
 (0)