Implement SelectionArea triple click gestures (#144563)

This change adds support for triple click to select a paragraph at the clicked position and triple click + drag to extend the selection paragraph-by-paragraph when using the SelectionArea widget.

This PR also:
* Makes `Text` widgets a `SelectionContainer` if a parent `SelectionRegistrar` exists.
* Fixes issues with selectable ordering involving `WidgetSpan`s.

Fixes: https://github.com/flutter/flutter/issues/104552
This commit is contained in:
Renzo Olivares 2024-04-02 14:10:52 -07:00 committed by GitHub
parent 6fe56362e5
commit 2a37c6f307
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 2438 additions and 227 deletions

View File

@ -185,6 +185,7 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection
_start = _end = null;
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
case SelectionEventType.selectParagraph:
_start = Offset.zero;
_end = Offset.infinite;
case SelectionEventType.granularlyExtendSelection:

File diff suppressed because it is too large Load Diff

View File

@ -299,6 +299,12 @@ enum SelectionEventType {
/// Used by [SelectWordSelectionEvent].
selectWord,
/// An event to select a paragraph at the location
/// [SelectParagraphSelectionEvent.globalPosition].
///
/// Used by [SelectParagraphSelectionEvent].
selectParagraph,
/// An event that extends the selection by a specific [TextGranularity].
granularlyExtendSelection,
@ -317,6 +323,9 @@ enum TextGranularity {
/// Treats word as an atomic unit when moving the selection handles.
word,
/// Treats a paragraph as an atomic unit when moving the selection handles.
paragraph,
/// Treats each line break as an atomic unit when moving the selection handles.
line,
@ -370,6 +379,21 @@ class SelectWordSelectionEvent extends SelectionEvent {
final Offset globalPosition;
}
/// Selects the entire paragraph at the location.
///
/// This event can be sent as the result of a triple click to select.
class SelectParagraphSelectionEvent extends SelectionEvent {
/// Creates a select paragraph event at the [globalPosition].
const SelectParagraphSelectionEvent({required this.globalPosition, this.absorb = false}): super._(SelectionEventType.selectParagraph);
/// The position in global coordinates to select paragraph at.
final Offset globalPosition;
/// Whether the selectable receiving the event should be absorbed into
/// an encompassing paragraph.
final bool absorb;
}
/// Updates a selection edge.
///
/// An active selection contains two edges, start and end. Use the [type] to

View File

@ -1443,6 +1443,7 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
_selectableStartEdgeUpdateRecords.remove(selectable);
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
case SelectionEventType.selectParagraph:
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
}

View File

@ -486,7 +486,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
// This method should be used in all instances when details.consecutiveTapCount
// would be used.
static int _getEffectiveConsecutiveTapCount(int rawCount) {
const int maxConsecutiveTap = 2;
const int maxConsecutiveTap = 3;
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
@ -555,6 +555,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
}
case 2:
_selectWordAt(offset: details.globalPosition);
case 3:
_selectParagraphAt(offset: details.globalPosition);
}
_updateSelectedContentIfNeeded();
}
@ -573,6 +575,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
_selectEndTo(offset: details.globalPosition, continuous: true);
case 2:
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
case 3:
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph);
}
_updateSelectedContentIfNeeded();
}
@ -997,6 +1001,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clears the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
/// * [_collapseSelectionAt], which collapses the selection at the location.
/// * [selectAll], which selects the entire content.
void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
@ -1037,6 +1042,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clears the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
/// * [_collapseSelectionAt], which collapses the selection at the location.
/// * [selectAll], which selects the entire content.
void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
@ -1052,12 +1058,15 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// Collapses the selection at the given `offset` location.
///
/// The `offset` is in global coordinates.
///
/// See also:
/// * [_selectStartTo], which sets or updates selection start edge.
/// * [_selectEndTo], which sets or updates selection end edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clears the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
/// * [selectAll], which selects the entire content.
void _collapseSelectionAt({required Offset offset}) {
_selectStartTo(offset: offset);
@ -1066,6 +1075,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// Selects a whole word at the `offset` location.
///
/// The `offset` is in global coordinates.
///
/// If the whole word is already in the current selection, selection won't
/// change. One call [_clearSelection] first if the selection needs to be
/// updated even if the word is already covered by the current selection.
@ -1079,6 +1090,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clears the ongoing selection.
/// * [_collapseSelectionAt], which collapses the selection at the location.
/// * [_selectParagraphAt], which selects an entire paragraph at the location.
/// * [selectAll], which selects the entire content.
void _selectWordAt({required Offset offset}) {
// There may be other selection ongoing.
@ -1086,6 +1098,30 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
_selectable?.dispatchSelectionEvent(SelectWordSelectionEvent(globalPosition: offset));
}
/// Selects the entire paragraph at the `offset` location.
///
/// The `offset` is in global coordinates.
///
/// If the paragraph is already in the current selection, selection won't
/// change. One call [_clearSelection] first if the selection needs to be
/// updated even if the paragraph is already covered by the current selection.
///
/// One can also use [_selectEndTo] or [_selectStartTo] to adjust the selection
/// edges after calling this method.
///
/// See also:
/// * [_selectStartTo], which sets or updates selection start edge.
/// * [_selectEndTo], which sets or updates selection end edge.
/// * [_finalizeSelection], which stops the `continuous` updates.
/// * [_clearSelection], which clear the ongoing selection.
/// * [_selectWordAt], which selects a whole word at the location.
/// * [selectAll], which selects the entire content.
void _selectParagraphAt({required Offset offset}) {
// There may be other selection ongoing.
_finalizeSelection();
_selectable?.dispatchSelectionEvent(SelectParagraphSelectionEvent(globalPosition: offset));
}
/// Stops any ongoing selection updates.
///
/// This method is different from [_clearSelection] that it does not remove
@ -1598,7 +1634,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
return result;
}
/// Selects a word in a selectable at the location
/// Selects a word in a [Selectable] at the location
/// [SelectWordSelectionEvent.globalPosition].
@override
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
@ -1613,6 +1649,21 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
return result;
}
/// Selects a paragraph in a [Selectable] at the location
/// [SelectParagraphSelectionEvent.globalPosition].
@override
SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) {
final SelectionResult result = super.handleSelectParagraph(event);
if (currentSelectionStartIndex != -1) {
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
}
if (currentSelectionEndIndex != -1) {
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
}
_updateLastEdgeEventsFromGeometries();
return result;
}
@override
SelectionResult handleClearSelection(ClearSelectionEvent event) {
final SelectionResult result = super.handleClearSelection(event);
@ -1654,6 +1705,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
_hasReceivedEndEvent.remove(selectable);
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
case SelectionEventType.selectParagraph:
break;
case SelectionEventType.granularlyExtendSelection:
case SelectionEventType.directionallyExtendSelection:
@ -1709,7 +1761,7 @@ class _SelectableRegionContainerDelegate extends MultiSelectableSelectionContain
}
}
/// An abstract base class for updating multiple selectable children.
/// An abstract base class for updating multiple [Selectable] children.
///
/// This class provide basic [SelectionEvent] handling and child [Selectable]
/// updating. The subclass needs to implement [ensureChildUpdated] to ensure
@ -1725,7 +1777,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
}
}
/// Gets the list of selectables this delegate is managing.
/// Gets the list of [Selectable]s this delegate is managing.
List<Selectable> selectables = <Selectable>[];
/// The number of additional pixels added to the selection handle drawable
@ -1741,11 +1793,11 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
/// This was an eyeballed value to create smooth user experiences.
static const double _kSelectionHandleDrawableAreaPadding = 5.0;
/// The current selectable that contains the selection end edge.
/// The current [Selectable] that contains the selection end edge.
@protected
int currentSelectionEndIndex = -1;
/// The current selectable that contains the selection start edge.
/// The current [Selectable] that contains the selection start edge.
@protected
int currentSelectionStartIndex = -1;
@ -1881,7 +1933,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
selectable.removeListener(_handleSelectableGeometryChange);
}
/// Called when this delegate finishes updating the selectables.
/// Called when this delegate finishes updating the [Selectable]s.
@protected
@mustCallSuper
void didChangeSelectables() {
@ -1905,7 +1957,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
_updateHandleLayersAndOwners();
}
Rect _getBoundingBox(Selectable selectable) {
static Rect _getBoundingBox(Selectable selectable) {
Rect result = selectable.boundingBoxes.first;
for (int index = 1; index < selectable.boundingBoxes.length; index += 1) {
result = result.expandToInclude(selectable.boundingBoxes[index]);
@ -1920,7 +1972,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
@protected
Comparator<Selectable> get compareOrder => _compareScreenOrder;
int _compareScreenOrder(Selectable a, Selectable b) {
static int _compareScreenOrder(Selectable a, Selectable b) {
final Rect rectA = MatrixUtils.transformRect(
a.getTransformTo(null),
_getBoundingBox(a),
@ -1982,7 +2034,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
_updateSelectionGeometry();
}
/// Gets the combined selection geometry for child selectables.
/// Gets the combined [SelectionGeometry] for child [Selectable]s.
@protected
SelectionGeometry getSelectionGeometry() {
if (currentSelectionEndIndex == -1 ||
@ -2158,7 +2210,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
_endHandleLayerOwner!.pushHandleLayers(null, effectiveEndHandle);
}
/// Copies the selected contents of all selectables.
/// Copies the selected contents of all [Selectable]s.
@override
SelectedContent? getSelectedContent() {
final List<SelectedContent> selections = <SelectedContent>[];
@ -2208,7 +2260,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
}
}
/// Selects all contents of all selectables.
/// Selects all contents of all [Selectable]s.
@protected
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
for (final Selectable selectable in selectables) {
@ -2219,23 +2271,27 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
return SelectionResult.none;
}
/// Selects a word in a selectable at the location
/// [SelectWordSelectionEvent.globalPosition].
@protected
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
SelectionResult _handleSelectBoundary(SelectionEvent event) {
assert(event is SelectWordSelectionEvent || event is SelectParagraphSelectionEvent, 'This method should only be given selection events that select text boundaries.');
late final Offset effectiveGlobalPosition;
if (event.type == SelectionEventType.selectWord) {
effectiveGlobalPosition = (event as SelectWordSelectionEvent).globalPosition;
} else if (event.type == SelectionEventType.selectParagraph) {
effectiveGlobalPosition = (event as SelectParagraphSelectionEvent).globalPosition;
}
SelectionResult? lastSelectionResult;
for (int index = 0; index < selectables.length; index += 1) {
bool globalRectsContainsPosition = false;
bool globalRectsContainPosition = false;
if (selectables[index].boundingBoxes.isNotEmpty) {
for (final Rect rect in selectables[index].boundingBoxes) {
final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect);
if (globalRect.contains(event.globalPosition)) {
globalRectsContainsPosition = true;
if (globalRect.contains(effectiveGlobalPosition)) {
globalRectsContainPosition = true;
break;
}
}
}
if (globalRectsContainsPosition) {
if (globalRectsContainPosition) {
final SelectionGeometry existingGeometry = selectables[index].value;
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
@ -2267,7 +2323,21 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
return SelectionResult.end;
}
/// Removes the selection of all selectables this delegate manages.
/// Selects a word in a [Selectable] at the location
/// [SelectWordSelectionEvent.globalPosition].
@protected
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
return _handleSelectBoundary(event);
}
/// Selects a paragraph in a [Selectable] at the location
/// [SelectParagraphSelectionEvent.globalPosition].
@protected
SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) {
return _handleSelectBoundary(event);
}
/// Removes the selection of all [Selectable]s this delegate manages.
@protected
SelectionResult handleClearSelection(ClearSelectionEvent event) {
for (final Selectable selectable in selectables) {
@ -2278,7 +2348,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
return SelectionResult.none;
}
/// Extend current selection in a certain text granularity.
/// Extend current selection in a certain [TextGranularity].
@protected
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
@ -2314,13 +2384,13 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
return result;
}
/// Extend current selection in a certain text granularity.
/// Extend current selection in a certain [TextGranularity].
@protected
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
if (currentSelectionStartIndex == -1) {
currentSelectionStartIndex = currentSelectionEndIndex = switch (event.direction) {
SelectionExtendDirection.previousLine || SelectionExtendDirection.backward => selectables.length,
SelectionExtendDirection.previousLine || SelectionExtendDirection.backward => selectables.length - 1,
SelectionExtendDirection.nextLine || SelectionExtendDirection.forward => 0,
};
}
@ -2396,6 +2466,9 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
case SelectionEventType.selectWord:
_extendSelectionInProgress = false;
result = handleSelectWord(event as SelectWordSelectionEvent);
case SelectionEventType.selectParagraph:
_extendSelectionInProgress = false;
result = handleSelectParagraph(event as SelectParagraphSelectionEvent);
case SelectionEventType.granularlyExtendSelection:
_extendSelectionInProgress = true;
result = handleGranularlyExtendSelection(event as GranularlyExtendSelectionEvent);
@ -2418,7 +2491,7 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
super.dispose();
}
/// Ensures the selectable child has received up to date selection event.
/// Ensures the [Selectable] child has received up to date selection event.
///
/// This method is called when a new [Selectable] is added to the delegate,
/// and its screen location falls into the previous selection.
@ -2428,10 +2501,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
@protected
void ensureChildUpdated(Selectable selectable);
/// Dispatches a selection event to a specific selectable.
/// Dispatches a selection event to a specific [Selectable].
///
/// Override this method if subclasses need to generate additional events or
/// treatments prior to sending the selection events.
/// treatments prior to sending the [SelectionEvent].
@protected
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
return selectable.dispatchSelectionEvent(event);

View File

@ -2,8 +2,10 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math';
import 'dart:ui' as ui show TextHeightBehavior;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'basic.dart';
@ -11,6 +13,7 @@ import 'default_selection_style.dart';
import 'framework.dart';
import 'inherited_theme.dart';
import 'media_query.dart';
import 'selectable_region.dart';
import 'selection_container.dart';
// Examples can assume:
@ -653,30 +656,47 @@ class Text extends StatelessWidget {
(null, final double textScaleFactor) => TextScaler.linear(textScaleFactor),
(null, null) => MediaQuery.textScalerOf(context),
};
Widget result = RichText(
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null.
locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null
softWrap: softWrap ?? defaultTextStyle.softWrap,
overflow: overflow ?? effectiveTextStyle?.overflow ?? defaultTextStyle.overflow,
textScaler: textScaler,
maxLines: maxLines ?? defaultTextStyle.maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
selectionRegistrar: registrar,
selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor,
text: TextSpan(
style: effectiveTextStyle,
text: data,
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
),
);
late Widget result;
if (registrar != null) {
result = MouseRegion(
cursor: DefaultSelectionStyle.of(context).mouseCursor ?? SystemMouseCursors.text,
child: result,
child: _SelectableTextContainer(
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null.
locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null
softWrap: softWrap ?? defaultTextStyle.softWrap,
overflow: overflow ?? effectiveTextStyle?.overflow ?? defaultTextStyle.overflow,
textScaler: textScaler,
maxLines: maxLines ?? defaultTextStyle.maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor,
text: TextSpan(
style: effectiveTextStyle,
text: data,
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
),
),
);
} else {
result = RichText(
textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
textDirection: textDirection, // RichText uses Directionality.of to obtain a default if this is null.
locale: locale, // RichText uses Localizations.localeOf to obtain a default if this is null
softWrap: softWrap ?? defaultTextStyle.softWrap,
overflow: overflow ?? effectiveTextStyle?.overflow ?? defaultTextStyle.overflow,
textScaler: textScaler,
maxLines: maxLines ?? defaultTextStyle.maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis ?? defaultTextStyle.textWidthBasis,
textHeightBehavior: textHeightBehavior ?? defaultTextStyle.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
selectionColor: selectionColor ?? DefaultSelectionStyle.of(context).selectionColor ?? DefaultSelectionStyle.defaultColor,
text: TextSpan(
style: effectiveTextStyle,
text: data,
children: textSpan != null ? <InlineSpan>[textSpan!] : null,
),
);
}
if (semanticsLabel != null) {
@ -713,3 +733,671 @@ class Text extends StatelessWidget {
}
}
}
class _SelectableTextContainer extends StatefulWidget {
const _SelectableTextContainer({
required this.text,
required this.textAlign,
this.textDirection,
required this.softWrap,
required this.overflow,
required this.textScaler,
this.maxLines,
this.locale,
this.strutStyle,
required this.textWidthBasis,
this.textHeightBehavior,
required this.selectionColor,
});
final InlineSpan text;
final TextAlign textAlign;
final TextDirection? textDirection;
final bool softWrap;
final TextOverflow overflow;
final TextScaler textScaler;
final int? maxLines;
final Locale? locale;
final StrutStyle? strutStyle;
final TextWidthBasis textWidthBasis;
final ui.TextHeightBehavior? textHeightBehavior;
final Color selectionColor;
@override
State<_SelectableTextContainer> createState() => _SelectableTextContainerState();
}
class _SelectableTextContainerState extends State<_SelectableTextContainer> {
late final _SelectableTextContainerDelegate _selectionDelegate;
final GlobalKey _textKey = GlobalKey();
@override
void initState() {
super.initState();
_selectionDelegate = _SelectableTextContainerDelegate(_textKey);
}
@override
void dispose() {
_selectionDelegate.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SelectionContainer(
delegate: _selectionDelegate,
// Use [_RichText] wrapper so the underlying [RenderParagraph] can register
// its [Selectable]s to the [SelectionContainer] created by this widget.
child: _RichText(
textKey: _textKey,
textAlign: widget.textAlign,
textDirection: widget.textDirection,
locale: widget.locale,
softWrap: widget.softWrap,
overflow: widget.overflow,
textScaler: widget.textScaler,
maxLines: widget.maxLines,
strutStyle: widget.strutStyle,
textWidthBasis: widget.textWidthBasis,
textHeightBehavior: widget.textHeightBehavior,
selectionColor: widget.selectionColor,
text: widget.text,
),
);
}
}
class _RichText extends StatelessWidget {
const _RichText({
this.textKey,
required this.text,
required this.textAlign,
this.textDirection,
required this.softWrap,
required this.overflow,
required this.textScaler,
this.maxLines,
this.locale,
this.strutStyle,
required this.textWidthBasis,
this.textHeightBehavior,
required this.selectionColor,
});
final GlobalKey? textKey;
final InlineSpan text;
final TextAlign textAlign;
final TextDirection? textDirection;
final bool softWrap;
final TextOverflow overflow;
final TextScaler textScaler;
final int? maxLines;
final Locale? locale;
final StrutStyle? strutStyle;
final TextWidthBasis textWidthBasis;
final ui.TextHeightBehavior? textHeightBehavior;
final Color selectionColor;
@override
Widget build(BuildContext context) {
final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
return RichText(
key: textKey,
textAlign: textAlign,
textDirection: textDirection,
locale: locale,
softWrap: softWrap,
overflow: overflow,
textScaler: textScaler,
maxLines: maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
selectionRegistrar: registrar,
selectionColor: selectionColor,
text: text,
);
}
}
// In practice some selectables like widgetspan shift several pixels. So when
// the vertical position diff is within the threshold, compare the horizontal
// position to make the compareScreenOrder function more robust.
const double _kSelectableVerticalComparingThreshold = 3.0;
class _SelectableTextContainerDelegate extends MultiSelectableSelectionContainerDelegate {
_SelectableTextContainerDelegate(
GlobalKey textKey,
) : _textKey = textKey;
final GlobalKey _textKey;
RenderParagraph get paragraph => _textKey.currentContext!.findRenderObject()! as RenderParagraph;
@override
SelectionResult handleSelectParagraph(SelectParagraphSelectionEvent event) {
final SelectionResult result = _handleSelectParagraph(event);
if (currentSelectionStartIndex != -1) {
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
}
if (currentSelectionEndIndex != -1) {
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
}
_updateLastEdgeEventsFromGeometries();
return result;
}
SelectionResult _handleSelectParagraph(SelectParagraphSelectionEvent event) {
if (event.absorb) {
for (int index = 0; index < selectables.length; index += 1) {
dispatchSelectionEventToChild(selectables[index], event);
}
currentSelectionStartIndex = 0;
currentSelectionEndIndex = selectables.length - 1;
return SelectionResult.next;
}
// First pass, if the position is on a placeholder then dispatch the selection
// event to the [Selectable] at the location and terminate.
for (int index = 0; index < selectables.length; index += 1) {
final bool selectableIsPlaceholder = !paragraph.selectableBelongsToParagraph(selectables[index]);
if (selectableIsPlaceholder && selectables[index].boundingBoxes.isNotEmpty) {
for (final Rect rect in selectables[index].boundingBoxes) {
final Rect globalRect = MatrixUtils.transformRect(selectables[index].getTransformTo(null), rect);
if (globalRect.contains(event.globalPosition)) {
currentSelectionStartIndex = currentSelectionEndIndex = index;
return dispatchSelectionEventToChild(selectables[index], event);
}
}
}
}
SelectionResult? lastSelectionResult;
bool foundStart = false;
int? lastNextIndex;
for (int index = 0; index < selectables.length; index += 1) {
if (!paragraph.selectableBelongsToParagraph(selectables[index])) {
if (foundStart) {
final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent(globalPosition: event.globalPosition, absorb: true);
final SelectionResult result = dispatchSelectionEventToChild(selectables[index], synthesizedEvent);
if (selectables.length - 1 == index) {
currentSelectionEndIndex = index;
_flushInactiveSelections();
return result;
}
}
continue;
}
final SelectionGeometry existingGeometry = selectables[index].value;
lastSelectionResult = dispatchSelectionEventToChild(selectables[index], event);
if (index == selectables.length - 1 && lastSelectionResult == SelectionResult.next) {
if (foundStart) {
currentSelectionEndIndex = index;
} else {
currentSelectionStartIndex = currentSelectionEndIndex = index;
}
return SelectionResult.next;
}
if (lastSelectionResult == SelectionResult.next) {
if (selectables[index].value == existingGeometry && !foundStart) {
lastNextIndex = index;
}
if (selectables[index].value != existingGeometry && !foundStart) {
assert(selectables[index].boundingBoxes.isNotEmpty);
assert(selectables[index].value.selectionRects.isNotEmpty);
final bool selectionAtStartOfSelectable = selectables[index].boundingBoxes[0].overlaps(selectables[index].value.selectionRects[0]);
int startIndex = 0;
if (lastNextIndex != null && selectionAtStartOfSelectable) {
startIndex = lastNextIndex + 1;
} else {
startIndex = lastNextIndex == null && selectionAtStartOfSelectable ? 0 : index;
}
for (int i = startIndex; i < index; i += 1) {
final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent(globalPosition: event.globalPosition, absorb: true);
dispatchSelectionEventToChild(selectables[i], synthesizedEvent);
}
currentSelectionStartIndex = startIndex;
foundStart = true;
}
continue;
}
if (index == 0 && lastSelectionResult == SelectionResult.previous) {
return SelectionResult.previous;
}
if (selectables[index].value != existingGeometry) {
if (!foundStart && lastNextIndex == null) {
currentSelectionStartIndex = 0;
for (int i = 0; i < index; i += 1) {
final SelectionEvent synthesizedEvent = SelectParagraphSelectionEvent(globalPosition: event.globalPosition, absorb: true);
dispatchSelectionEventToChild(selectables[i], synthesizedEvent);
}
}
currentSelectionEndIndex = index;
// Geometry has changed as a result of select paragraph, need to clear the
// selection of other selectables to keep selection in sync.
_flushInactiveSelections();
}
return SelectionResult.end;
}
assert(lastSelectionResult == null);
return SelectionResult.end;
}
/// Initializes the selection of the selectable children.
///
/// The goal is to find the selectable child that contains the selection edge.
/// Returns [SelectionResult.end] if the selection edge ends on any of the
/// children. Otherwise, it returns [SelectionResult.previous] if the selection
/// does not reach any of its children. Returns [SelectionResult.next]
/// if the selection reaches the end of its children.
///
/// Ideally, this method should only be called twice at the beginning of the
/// drag selection, once for start edge update event, once for end edge update
/// event.
SelectionResult _initSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
assert((isEnd && currentSelectionEndIndex == -1) || (!isEnd && currentSelectionStartIndex == -1));
SelectionResult? finalResult;
// Begin the search for the selection edge at the opposite edge if it exists.
final bool hasOppositeEdge = isEnd ? currentSelectionStartIndex != -1 : currentSelectionEndIndex != -1;
int newIndex = switch ((isEnd, hasOppositeEdge)) {
(true, true) => currentSelectionStartIndex,
(true, false) => 0,
(false, true) => currentSelectionEndIndex,
(false, false) => 0,
};
bool? forward;
late SelectionResult currentSelectableResult;
// This loop sends the selection event to one of the following to determine
// the direction of the search.
// - The opposite edge index if it exists.
// - Index 0 if the opposite edge index does not exist.
//
// If the result is `SelectionResult.next`, this loop look backward.
// Otherwise, it looks forward.
//
// The terminate condition are:
// 1. the selectable returns end, pending, none.
// 2. the selectable returns previous when looking forward.
// 2. the selectable returns next when looking backward.
while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) {
currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event);
switch (currentSelectableResult) {
case SelectionResult.end:
case SelectionResult.pending:
case SelectionResult.none:
finalResult = currentSelectableResult;
case SelectionResult.next:
if (forward == false) {
newIndex += 1;
finalResult = SelectionResult.end;
} else if (newIndex == selectables.length - 1) {
finalResult = currentSelectableResult;
} else {
forward = true;
newIndex += 1;
}
case SelectionResult.previous:
if (forward ?? false) {
newIndex -= 1;
finalResult = SelectionResult.end;
} else if (newIndex == 0) {
finalResult = currentSelectableResult;
} else {
forward = false;
newIndex -= 1;
}
}
}
if (isEnd) {
currentSelectionEndIndex = newIndex;
} else {
currentSelectionStartIndex = newIndex;
}
_flushInactiveSelections();
return finalResult!;
}
SelectionResult _adjustSelection(SelectionEdgeUpdateEvent event, {required bool isEnd}) {
assert(() {
if (isEnd) {
assert(currentSelectionEndIndex < selectables.length && currentSelectionEndIndex >= 0);
return true;
}
assert(currentSelectionStartIndex < selectables.length && currentSelectionStartIndex >= 0);
return true;
}());
SelectionResult? finalResult;
// Determines if the edge being adjusted is within the current viewport.
// - If so, we begin the search for the new selection edge position at the
// currentSelectionEndIndex/currentSelectionStartIndex.
// - If not, we attempt to locate the new selection edge starting from
// the opposite end.
// - If neither edge is in the current viewport, the search for the new
// selection edge position begins at 0.
//
// This can happen when there is a scrollable child and the edge being adjusted
// has been scrolled out of view.
final bool isCurrentEdgeWithinViewport = isEnd ? value.endSelectionPoint != null : value.startSelectionPoint != null;
final bool isOppositeEdgeWithinViewport = isEnd ? value.startSelectionPoint != null : value.endSelectionPoint != null;
int newIndex = switch ((isEnd, isCurrentEdgeWithinViewport, isOppositeEdgeWithinViewport)) {
(true, true, true) => currentSelectionEndIndex,
(true, true, false) => currentSelectionEndIndex,
(true, false, true) => currentSelectionStartIndex,
(true, false, false) => 0,
(false, true, true) => currentSelectionStartIndex,
(false, true, false) => currentSelectionStartIndex,
(false, false, true) => currentSelectionEndIndex,
(false, false, false) => 0,
};
bool? forward;
late SelectionResult currentSelectableResult;
// This loop sends the selection event to one of the following to determine
// the direction of the search.
// - currentSelectionEndIndex/currentSelectionStartIndex if the current edge
// is in the current viewport.
// - The opposite edge index if the current edge is not in the current viewport.
// - Index 0 if neither edge is in the current viewport.
//
// If the result is `SelectionResult.next`, this loop look backward.
// Otherwise, it looks forward.
//
// The terminate condition are:
// 1. the selectable returns end, pending, none.
// 2. the selectable returns previous when looking forward.
// 2. the selectable returns next when looking backward.
while (newIndex < selectables.length && newIndex >= 0 && finalResult == null) {
currentSelectableResult = dispatchSelectionEventToChild(selectables[newIndex], event);
switch (currentSelectableResult) {
case SelectionResult.end:
case SelectionResult.pending:
case SelectionResult.none:
finalResult = currentSelectableResult;
case SelectionResult.next:
if (forward == false) {
newIndex += 1;
finalResult = SelectionResult.end;
} else if (newIndex == selectables.length - 1) {
finalResult = currentSelectableResult;
} else {
forward = true;
newIndex += 1;
}
case SelectionResult.previous:
if (forward ?? false) {
newIndex -= 1;
finalResult = SelectionResult.end;
} else if (newIndex == 0) {
finalResult = currentSelectableResult;
} else {
forward = false;
newIndex -= 1;
}
}
}
if (isEnd) {
final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex;
if (forward != null && ((!forwardSelection && forward && newIndex >= currentSelectionStartIndex) || (forwardSelection && !forward && newIndex <= currentSelectionStartIndex))) {
currentSelectionStartIndex = currentSelectionEndIndex;
}
currentSelectionEndIndex = newIndex;
} else {
final bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex;
if (forward != null && ((!forwardSelection && !forward && newIndex <= currentSelectionEndIndex) || (forwardSelection && forward && newIndex >= currentSelectionEndIndex))) {
currentSelectionEndIndex = currentSelectionStartIndex;
}
currentSelectionStartIndex = newIndex;
}
_flushInactiveSelections();
return finalResult!;
}
/// The compare function this delegate used for determining the selection
/// order of the [Selectable]s.
///
/// Sorts the [Selectable]s by their top left [Rect].
@override
Comparator<Selectable> get compareOrder => _compareScreenOrder;
static int _compareScreenOrder(Selectable a, Selectable b) {
// Attempt to sort the selectables under a [_SelectableTextContainerDelegate]
// by the top left rect.
final Rect rectA = MatrixUtils.transformRect(
a.getTransformTo(null),
a.boundingBoxes.first,
);
final Rect rectB = MatrixUtils.transformRect(
b.getTransformTo(null),
b.boundingBoxes.first,
);
final int result = _compareVertically(rectA, rectB);
if (result != 0) {
return result;
}
return _compareHorizontally(rectA, rectB);
}
/// Compares two rectangles in the screen order solely by their vertical
/// positions.
///
/// Returns positive if a is lower, negative if a is higher, 0 if their
/// order can't be determine solely by their vertical position.
static int _compareVertically(Rect a, Rect b) {
// The rectangles overlap so defer to horizontal comparison.
if ((a.top - b.top < _kSelectableVerticalComparingThreshold && a.bottom - b.bottom > - _kSelectableVerticalComparingThreshold) ||
(b.top - a.top < _kSelectableVerticalComparingThreshold && b.bottom - a.bottom > - _kSelectableVerticalComparingThreshold)) {
return 0;
}
if ((a.top - b.top).abs() > _kSelectableVerticalComparingThreshold) {
return a.top > b.top ? 1 : -1;
}
return a.bottom > b.bottom ? 1 : -1;
}
/// Compares two rectangles in the screen order by their horizontal positions
/// assuming one of the rectangles enclose the other rect vertically.
///
/// Returns positive if a is lower, negative if a is higher.
static int _compareHorizontally(Rect a, Rect b) {
// a encloses b.
if (a.left - b.left < precisionErrorTolerance && a.right - b.right > - precisionErrorTolerance) {
return -1;
}
// b encloses a.
if (b.left - a.left < precisionErrorTolerance && b.right - a.right > - precisionErrorTolerance) {
return 1;
}
if ((a.left - b.left).abs() > precisionErrorTolerance) {
return a.left > b.left ? 1 : -1;
}
return a.right > b.right ? 1 : -1;
}
// From [SelectableRegion].
// Clears the selection on all selectables not in the range of
// currentSelectionStartIndex..currentSelectionEndIndex.
//
// If one of the edges does not exist, then this method will clear the selection
// in all selectables except the existing edge.
//
// If neither of the edges exist this method immediately returns.
void _flushInactiveSelections() {
if (currentSelectionStartIndex == -1 && currentSelectionEndIndex == -1) {
return;
}
if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) {
final int skipIndex = currentSelectionStartIndex == -1 ? currentSelectionEndIndex : currentSelectionStartIndex;
selectables
.where((Selectable target) => target != selectables[skipIndex])
.forEach((Selectable target) => dispatchSelectionEventToChild(target, const ClearSelectionEvent()));
return;
}
final int skipStart = min(currentSelectionStartIndex, currentSelectionEndIndex);
final int skipEnd = max(currentSelectionStartIndex, currentSelectionEndIndex);
for (int index = 0; index < selectables.length; index += 1) {
if (index >= skipStart && index <= skipEnd) {
continue;
}
dispatchSelectionEventToChild(selectables[index], const ClearSelectionEvent());
}
}
final Set<Selectable> _hasReceivedStartEvent = <Selectable>{};
final Set<Selectable> _hasReceivedEndEvent = <Selectable>{};
Offset? _lastStartEdgeUpdateGlobalPosition;
Offset? _lastEndEdgeUpdateGlobalPosition;
@override
void remove(Selectable selectable) {
_hasReceivedStartEvent.remove(selectable);
_hasReceivedEndEvent.remove(selectable);
super.remove(selectable);
}
void _updateLastEdgeEventsFromGeometries() {
if (currentSelectionStartIndex != -1 && selectables[currentSelectionStartIndex].value.hasSelection) {
final Selectable start = selectables[currentSelectionStartIndex];
final Offset localStartEdge = start.value.startSelectionPoint!.localPosition +
Offset(0, - start.value.startSelectionPoint!.lineHeight / 2);
_lastStartEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(start.getTransformTo(null), localStartEdge);
}
if (currentSelectionEndIndex != -1 && selectables[currentSelectionEndIndex].value.hasSelection) {
final Selectable end = selectables[currentSelectionEndIndex];
final Offset localEndEdge = end.value.endSelectionPoint!.localPosition +
Offset(0, -end.value.endSelectionPoint!.lineHeight / 2);
_lastEndEdgeUpdateGlobalPosition = MatrixUtils.transformPoint(end.getTransformTo(null), localEndEdge);
}
}
@override
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
final SelectionResult result = super.handleSelectAll(event);
for (final Selectable selectable in selectables) {
_hasReceivedStartEvent.add(selectable);
_hasReceivedEndEvent.add(selectable);
}
// Synthesize last update event so the edge updates continue to work.
_updateLastEdgeEventsFromGeometries();
return result;
}
/// Selects a word in a selectable at the location
/// [SelectWordSelectionEvent.globalPosition].
@override
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
final SelectionResult result = super.handleSelectWord(event);
if (currentSelectionStartIndex != -1) {
_hasReceivedStartEvent.add(selectables[currentSelectionStartIndex]);
}
if (currentSelectionEndIndex != -1) {
_hasReceivedEndEvent.add(selectables[currentSelectionEndIndex]);
}
_updateLastEdgeEventsFromGeometries();
return result;
}
@override
SelectionResult handleClearSelection(ClearSelectionEvent event) {
final SelectionResult result = super.handleClearSelection(event);
_hasReceivedStartEvent.clear();
_hasReceivedEndEvent.clear();
_lastStartEdgeUpdateGlobalPosition = null;
_lastEndEdgeUpdateGlobalPosition = null;
return result;
}
@override
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
if (event.type == SelectionEventType.endEdgeUpdate) {
_lastEndEdgeUpdateGlobalPosition = event.globalPosition;
} else {
_lastStartEdgeUpdateGlobalPosition = event.globalPosition;
}
if (event.granularity == TextGranularity.paragraph) {
if (event.type == SelectionEventType.endEdgeUpdate) {
return currentSelectionEndIndex == -1 ? _initSelection(event, isEnd: true) : _adjustSelection(event, isEnd: true);
}
return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false);
}
return super.handleSelectionEdgeUpdate(event);
}
@override
void dispose() {
_hasReceivedStartEvent.clear();
_hasReceivedEndEvent.clear();
super.dispose();
}
@override
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
switch (event.type) {
case SelectionEventType.startEdgeUpdate:
_hasReceivedStartEvent.add(selectable);
ensureChildUpdated(selectable);
case SelectionEventType.endEdgeUpdate:
_hasReceivedEndEvent.add(selectable);
ensureChildUpdated(selectable);
case SelectionEventType.clear:
_hasReceivedStartEvent.remove(selectable);
_hasReceivedEndEvent.remove(selectable);
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
case SelectionEventType.selectParagraph:
break;
case SelectionEventType.granularlyExtendSelection:
case SelectionEventType.directionallyExtendSelection:
_hasReceivedStartEvent.add(selectable);
_hasReceivedEndEvent.add(selectable);
ensureChildUpdated(selectable);
}
return super.dispatchSelectionEventToChild(selectable, event);
}
@override
void ensureChildUpdated(Selectable selectable) {
if (_lastEndEdgeUpdateGlobalPosition != null && _hasReceivedEndEvent.add(selectable)) {
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forEnd(
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
);
if (currentSelectionEndIndex == -1) {
handleSelectionEdgeUpdate(synthesizedEvent);
}
selectable.dispatchSelectionEvent(synthesizedEvent);
}
if (_lastStartEdgeUpdateGlobalPosition != null && _hasReceivedStartEvent.add(selectable)) {
final SelectionEdgeUpdateEvent synthesizedEvent = SelectionEdgeUpdateEvent.forStart(
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
);
if (currentSelectionStartIndex == -1) {
handleSelectionEdgeUpdate(synthesizedEvent);
}
selectable.dispatchSelectionEvent(synthesizedEvent);
}
}
@override
void didChangeSelectables() {
if (_lastEndEdgeUpdateGlobalPosition != null) {
handleSelectionEdgeUpdate(
SelectionEdgeUpdateEvent.forEnd(
globalPosition: _lastEndEdgeUpdateGlobalPosition!,
),
);
}
if (_lastStartEdgeUpdateGlobalPosition != null) {
handleSelectionEdgeUpdate(
SelectionEdgeUpdateEvent.forStart(
globalPosition: _lastStartEdgeUpdateGlobalPosition!,
),
);
}
final Set<Selectable> selectableSet = selectables.toSet();
_hasReceivedEndEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
_hasReceivedStartEvent.removeWhere((Selectable selectable) => !selectableSet.contains(selectable));
super.didChangeSelectables();
}
}

View File

@ -190,6 +190,112 @@ void main() {
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on triple-click drag', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
expect(paragraph2.selections.isEmpty, isTrue);
await gesture.moveTo(textOffsetToPosition(paragraph2, 4));
// Should select paragraph 2.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
expect(paragraph3.selections.isEmpty, isTrue);
await gesture.moveTo(textOffsetToPosition(paragraph3, 3));
// Should select paragraph 3.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph4 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 4'), matching: find.byType(RichText)));
expect(paragraph4.selections.isEmpty, isTrue);
await gesture.moveTo(textOffsetToPosition(paragraph4, 3));
// Should select paragraph 4.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph4.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on triple-click drag - horizontal', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: SelectionArea(
selectionControls: materialTextSelectionControls,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 100,
itemBuilder: (BuildContext context, int index) {
return Text('Item $index');
},
),
),
));
await tester.pumpAndSettle();
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 0'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: ui.PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
expect(paragraph2.selections.isEmpty, isTrue);
await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 50));
// Should select paragraph 2.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 2'), matching: find.byType(RichText)));
expect(paragraph3.selections.isEmpty, isTrue);
await gesture.moveTo(textOffsetToPosition(paragraph3, 5) + const Offset(0, 50));
// Should select paragraph 3.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('select to scroll forward', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
addTearDown(controller.dispose);

View File

@ -883,6 +883,323 @@ void main() {
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select paragraph-by-paragraph on triple click drag', (WidgetTester tester) async {
const String longText = 'Hello world this is some long piece of text '
'that will represent a long paragraph, when triple clicking this block '
'of text all of it will be selected.\n'
'This will be the start of a new line. When triple clicking this block '
'of text all of it should be selected.';
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Center(
child: Text(longText),
),
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text(longText), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 2));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 150));
await gesture.moveTo(textOffsetToPosition(paragraph, 155));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 257));
await gesture.moveTo(textOffsetToPosition(paragraph, 170));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 257));
// Check backward selection.
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 150));
// Start a new triple-click drag.
await gesture.up();
await tester.pumpAndSettle(kDoubleTapTimeout);
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pumpAndSettle();
await gesture.up();
expect(paragraph.selections.isNotEmpty, isTrue);
expect(paragraph.selections.length, 1);
expect(paragraph.selections.first, const TextSelection.collapsed(offset: 151));
await tester.pump(kDoubleTapTimeout);
// Triple-click.
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph, 151));
await tester.pumpAndSettle();
expect(paragraph.selections[0], const TextSelection(baseOffset: 150, extentOffset: 257));
// Selecting across line should select to the end.
await gesture.moveTo(textOffsetToPosition(paragraph, 5) + const Offset(0.0, -200.0));
await tester.pump();
expect(paragraph.selections[0], const TextSelection(baseOffset: 257, extentOffset: 0));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on triple click drag when selecting inside a WidgetSpan', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Text.rich(
WidgetSpan(
child: Column(
children: <Widget>[
Text('Text widget A.'),
Text('Text widget B.'),
Text('Text widget C.'),
Text('Text widget D.'),
Text('Text widget E.'),
],
),
),
),
),
),
);
final RenderParagraph paragraphC = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Text widget C.'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraphC, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraphC, 2));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraphC, 2));
await tester.pumpAndSettle();
expect(paragraphC.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
await gesture.moveTo(textOffsetToPosition(paragraphC, 7));
await tester.pump();
expect(paragraphC.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
final RenderParagraph paragraphE = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Text widget E.'), matching: find.byType(RichText)));
final RenderParagraph paragraphD = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Text widget D.'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraphE, 5));
// Should select line C-E.
expect(paragraphC.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraphD.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
expect(paragraphE.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on triple click drag', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?\nThis is the first text widget.'),
Text('Good, and you?\nThis is the second text widget.'),
Text('Fine, thank you.\nThis is the third text widget.'),
],
),
),
),
);
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('first text widget'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph1, 2));
await tester.pumpAndSettle();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 13));
await gesture.moveTo(textOffsetToPosition(paragraph1, 14));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('second text widget'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should select line 1 of text widget 2.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 15));
await gesture.moveTo(textOffsetToPosition(paragraph2, 16));
// Should select the rest of text widget 2.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('third text widget'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
// Should select line 1 of text widget 3.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 17));
await gesture.moveTo(textOffsetToPosition(paragraph3, 18));
// Should select the rest of text widget 3.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 43));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 47));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets on triple click drag and return to origin paragraph', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?\nThis is the first text widget.'),
Text('Good, and you?\nThis is the second text widget.'),
Text('Fine, thank you.\nThis is the third text widget.'),
],
),
),
),
);
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('second text widget'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph2, 2), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph2, 2));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph2, 2));
await tester.pumpAndSettle();
// Should select line 1 of text widget 2.
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 15));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('first text widget'), matching: find.byType(RichText)));
// Should select line 2 of text widget 1.
await gesture.moveTo(textOffsetToPosition(paragraph1, 14));
await tester.pump();
expect(paragraph1.selections[0], const TextSelection(baseOffset: 43, extentOffset: 13));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 15, extentOffset: 0));
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('third text widget'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph1, 5));
// Should select rest of text widget 1.
expect(paragraph1.selections[0], const TextSelection(baseOffset: 43, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 15, extentOffset: 0));
await gesture.moveTo(textOffsetToPosition(paragraph2, 2));
// Should clear the selection on paragraph 1 and return to the origin paragraph.
expect(paragraph1.selections.isEmpty, true);
expect(paragraph2.selections[0], const TextSelection(baseOffset: 15, extentOffset: 0));
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
// Should select line 1 of text widget 3.
expect(paragraph1.selections.isEmpty, true);
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 17));
await gesture.moveTo(textOffsetToPosition(paragraph3, 18));
// Should select line 2 of text widget 3.
expect(paragraph1.selections.isEmpty, true);
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 46));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 47));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
// Should clear the selection on paragraph 3 and return to the origin paragraph.
expect(paragraph1.selections.isEmpty, true);
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 15));
expect(paragraph3.selections.isEmpty, true);
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can reverse selection across multiple widgets on triple click drag', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: SelectableRegion(
focusNode: FocusNode(),
selectionControls: materialTextSelectionControls,
child: const Column(
children: <Widget>[
Text('How are you?\nThis is the first text widget.'),
Text('Good, and you?\nThis is the second text widget.'),
Text('Fine, thank you.\nThis is the third text widget.'),
],
),
),
),
);
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Fine, thank you.'), matching: find.byType(RichText)));
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 18), kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph3, 18));
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(textOffsetToPosition(paragraph3, 18));
await tester.pumpAndSettle();
expect(paragraph3.selections[0], const TextSelection(baseOffset: 17, extentOffset: 47));
await gesture.moveTo(textOffsetToPosition(paragraph3, 4));
await tester.pump();
expect(paragraph3.selections[0], const TextSelection(baseOffset: 47, extentOffset: 0));
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('Good, and you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 47, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 46, extentOffset: 0));
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.textContaining('How are you?'), matching: find.byType(RichText)));
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
expect(paragraph3.selections[0], const TextSelection(baseOffset: 47, extentOffset: 0));
expect(paragraph2.selections[0], const TextSelection(baseOffset: 46, extentOffset: 0));
expect(paragraph1.selections[0], const TextSelection(baseOffset: 43, extentOffset: 0));
await gesture.up();
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
testWidgets('mouse can select multiple widgets', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);

View File

@ -9,15 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
void main() {
Future<void> pumpContainer(WidgetTester tester, Widget child) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: DefaultSelectionStyle(
selectionColor: Colors.red,
child: child,
),
),
);
await tester.pumpWidget(MaterialApp(home: child));
}
testWidgets('updates its registrar and delegate based on the number of selectables', (WidgetTester tester) async {
@ -89,7 +81,7 @@ void main() {
child: const Text('dummy'),
);
},
)
),
),
);
await tester.pumpAndSettle();
@ -131,17 +123,17 @@ void main() {
await pumpContainer(
tester,
SelectionContainer(
registrar: registrar,
delegate: delegate,
child: Builder(
builder: (BuildContext context) {
return SelectionContainer(
registrar: SelectionContainer.maybeOf(context),
delegate: childDelegate,
child: const Text('dummy'),
);
},
)
registrar: registrar,
delegate: delegate,
child: Builder(
builder: (BuildContext context) {
return SelectionContainer(
registrar: SelectionContainer.maybeOf(context),
delegate: childDelegate,
child: const Text('dummy'),// The [Text] widget has an internal [SelectionContainer].
);
},
),
),
);
await tester.pump();