Add double click and double click + drag gestures to SelectionArea (#124817)
Adds double click to select a word. Adds double click + drag to select word by word. https://user-images.githubusercontent.com/948037/234363577-941c36bc-ac42-4b7f-84aa-26b106c9ff05.mov Partially fixes #104552
This commit is contained in:
parent
b3096225e0
commit
457d00449d
@ -18,6 +18,9 @@ import 'layout_helper.dart';
|
||||
import 'object.dart';
|
||||
import 'selection.dart';
|
||||
|
||||
/// The start and end positions for a word.
|
||||
typedef _WordBoundaryRecord = ({TextPosition wordStart, TextPosition wordEnd});
|
||||
|
||||
const String _kEllipsis = '\u2026';
|
||||
|
||||
/// Used by the [RenderParagraph] to map its rendering children to their
|
||||
@ -1329,6 +1332,8 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
||||
TextPosition? _textSelectionStart;
|
||||
TextPosition? _textSelectionEnd;
|
||||
|
||||
bool _selectableContainsOriginWord = false;
|
||||
|
||||
LayerLink? _startHandleLayerLink;
|
||||
LayerLink? _endHandleLayerLink;
|
||||
|
||||
@ -1397,7 +1402,17 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
||||
case SelectionEventType.startEdgeUpdate:
|
||||
case SelectionEventType.endEdgeUpdate:
|
||||
final SelectionEdgeUpdateEvent edgeUpdate = event as SelectionEdgeUpdateEvent;
|
||||
result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate);
|
||||
final TextGranularity granularity = event.granularity;
|
||||
|
||||
switch (granularity) {
|
||||
case TextGranularity.character:
|
||||
result = _updateSelectionEdge(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate);
|
||||
case TextGranularity.word:
|
||||
result = _updateSelectionEdgeByWord(edgeUpdate.globalPosition, isEnd: edgeUpdate.type == SelectionEventType.endEdgeUpdate);
|
||||
case TextGranularity.document:
|
||||
case TextGranularity.line:
|
||||
assert(false, 'Moving the selection edge by line or document is not supported.');
|
||||
}
|
||||
case SelectionEventType.clear:
|
||||
result = _handleClearSelection();
|
||||
case SelectionEventType.selectAll:
|
||||
@ -1474,6 +1489,199 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
||||
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
||||
}
|
||||
|
||||
TextPosition _closestWordBoundary(
|
||||
_WordBoundaryRecord wordBoundary,
|
||||
TextPosition position,
|
||||
) {
|
||||
final int differenceA = (position.offset - wordBoundary.wordStart.offset).abs();
|
||||
final int differenceB = (position.offset - wordBoundary.wordEnd.offset).abs();
|
||||
return differenceA < differenceB ? wordBoundary.wordStart : wordBoundary.wordEnd;
|
||||
}
|
||||
|
||||
TextPosition _updateSelectionStartEdgeByWord(
|
||||
_WordBoundaryRecord? wordBoundary,
|
||||
TextPosition position,
|
||||
TextPosition? existingSelectionStart,
|
||||
TextPosition? existingSelectionEnd,
|
||||
) {
|
||||
TextPosition? targetPosition;
|
||||
if (wordBoundary != null) {
|
||||
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
|
||||
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
|
||||
final bool isSamePosition = position.offset == existingSelectionEnd.offset;
|
||||
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
|
||||
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset));
|
||||
if (shouldSwapEdges) {
|
||||
if (position.offset < existingSelectionEnd.offset) {
|
||||
targetPosition = wordBoundary.wordStart;
|
||||
} else {
|
||||
targetPosition = wordBoundary.wordEnd;
|
||||
}
|
||||
// When the selection is inverted by the new position it is necessary to
|
||||
// swap the start edge (moving edge) with the end edge (static edge) to
|
||||
// maintain the origin word within the selection.
|
||||
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd);
|
||||
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
|
||||
_setSelectionPosition(existingSelectionEnd.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true);
|
||||
} else {
|
||||
if (position.offset < existingSelectionEnd.offset) {
|
||||
targetPosition = wordBoundary.wordStart;
|
||||
} else if (position.offset > existingSelectionEnd.offset) {
|
||||
targetPosition = wordBoundary.wordEnd;
|
||||
} else {
|
||||
// Keep the origin word in bounds when position is at the static edge.
|
||||
targetPosition = existingSelectionStart;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (existingSelectionEnd != null) {
|
||||
// If the end edge exists and the start edge is being moved, then the
|
||||
// start edge is moved to encompass the entire word at the new position.
|
||||
if (position.offset < existingSelectionEnd.offset) {
|
||||
targetPosition = wordBoundary.wordStart;
|
||||
} else {
|
||||
targetPosition = wordBoundary.wordEnd;
|
||||
}
|
||||
} else {
|
||||
// Move the start edge to the closest word boundary.
|
||||
targetPosition = _closestWordBoundary(wordBoundary, position);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The position is not contained within the current rect. The targetPosition
|
||||
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
|
||||
// for a more in depth explanation on this adjustment.
|
||||
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
|
||||
// When the selection is inverted by the new position it is necessary to
|
||||
// swap the start edge (moving edge) with the end edge (static edge) to
|
||||
// maintain the origin word within the selection.
|
||||
final bool isSamePosition = position.offset == existingSelectionEnd.offset;
|
||||
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
|
||||
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset > existingSelectionEnd.offset));
|
||||
|
||||
if (shouldSwapEdges) {
|
||||
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionEnd);
|
||||
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
|
||||
_setSelectionPosition(isSelectionInverted ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPosition ?? position;
|
||||
}
|
||||
|
||||
TextPosition _updateSelectionEndEdgeByWord(
|
||||
_WordBoundaryRecord? wordBoundary,
|
||||
TextPosition position,
|
||||
TextPosition? existingSelectionStart,
|
||||
TextPosition? existingSelectionEnd,
|
||||
) {
|
||||
TextPosition? targetPosition;
|
||||
if (wordBoundary != null) {
|
||||
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
|
||||
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
|
||||
final bool isSamePosition = position.offset == existingSelectionStart.offset;
|
||||
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
|
||||
final bool shouldSwapEdges = !isSamePosition && (isSelectionInverted != (position.offset < existingSelectionStart.offset));
|
||||
if (shouldSwapEdges) {
|
||||
if (position.offset < existingSelectionStart.offset) {
|
||||
targetPosition = wordBoundary.wordStart;
|
||||
} else {
|
||||
targetPosition = wordBoundary.wordEnd;
|
||||
}
|
||||
// When the selection is inverted by the new position it is necessary to
|
||||
// swap the end edge (moving edge) with the start edge (static edge) to
|
||||
// maintain the origin word within the selection.
|
||||
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart);
|
||||
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
|
||||
_setSelectionPosition(existingSelectionStart.offset == localWordBoundary.wordStart.offset ? localWordBoundary.wordEnd : localWordBoundary.wordStart, isEnd: false);
|
||||
} else {
|
||||
if (position.offset < existingSelectionStart.offset) {
|
||||
targetPosition = wordBoundary.wordStart;
|
||||
} else if (position.offset > existingSelectionStart.offset) {
|
||||
targetPosition = wordBoundary.wordEnd;
|
||||
} else {
|
||||
// Keep the origin word in bounds when position is at the static edge.
|
||||
targetPosition = existingSelectionEnd;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (existingSelectionStart != null) {
|
||||
// If the start edge exists and the end edge is being moved, then the
|
||||
// end edge is moved to encompass the entire word at the new position.
|
||||
if (position.offset < existingSelectionStart.offset) {
|
||||
targetPosition = wordBoundary.wordStart;
|
||||
} else {
|
||||
targetPosition = wordBoundary.wordEnd;
|
||||
}
|
||||
} else {
|
||||
// Move the end edge to the closest word boundary.
|
||||
targetPosition = _closestWordBoundary(wordBoundary, position);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The position is not contained within the current rect. The targetPosition
|
||||
// will either be at the end or beginning of the current rect. See [SelectionUtils.adjustDragOffset]
|
||||
// for a more in depth explanation on this adjustment.
|
||||
if (_selectableContainsOriginWord && existingSelectionStart != null && existingSelectionEnd != null) {
|
||||
// When the selection is inverted by the new position it is necessary to
|
||||
// swap the end edge (moving edge) with the start edge (static edge) to
|
||||
// maintain the origin word within the selection.
|
||||
final bool isSamePosition = position.offset == existingSelectionStart.offset;
|
||||
final bool isSelectionInverted = existingSelectionStart.offset > existingSelectionEnd.offset;
|
||||
final bool shouldSwapEdges = isSelectionInverted != (position.offset < existingSelectionStart.offset) || isSamePosition;
|
||||
if (shouldSwapEdges) {
|
||||
final _WordBoundaryRecord localWordBoundary = _getWordBoundaryAtPosition(existingSelectionStart);
|
||||
assert(localWordBoundary.wordStart.offset >= range.start && localWordBoundary.wordEnd.offset <= range.end);
|
||||
_setSelectionPosition(isSelectionInverted ? localWordBoundary.wordStart : localWordBoundary.wordEnd, isEnd: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPosition ?? position;
|
||||
}
|
||||
|
||||
SelectionResult _updateSelectionEdgeByWord(Offset globalPosition, {required bool isEnd}) {
|
||||
// When the start/end edges are swapped, i.e. the start is after the end, and
|
||||
// the scrollable synthesizes an event for the opposite edge, this will potentially
|
||||
// move the opposite edge outside of the origin word boundary and we are unable to recover.
|
||||
final TextPosition? existingSelectionStart = _textSelectionStart;
|
||||
final TextPosition? existingSelectionEnd = _textSelectionEnd;
|
||||
|
||||
_setSelectionPosition(null, isEnd: isEnd);
|
||||
final Matrix4 transform = paragraph.getTransformTo(null);
|
||||
transform.invert();
|
||||
final Offset localPosition = MatrixUtils.transformPoint(transform, globalPosition);
|
||||
if (_rect.isEmpty) {
|
||||
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
||||
}
|
||||
final Offset adjustedOffset = SelectionUtils.adjustDragOffset(
|
||||
_rect,
|
||||
localPosition,
|
||||
direction: paragraph.textDirection,
|
||||
);
|
||||
|
||||
final TextPosition position = paragraph.getPositionForOffset(adjustedOffset);
|
||||
// Check if the original local position is within the rect, if it is not then
|
||||
// we do not need to look up the word boundary for that position. This is to
|
||||
// maintain a selectables selection collapsed at 0 when the local position is
|
||||
// not located inside its rect.
|
||||
final _WordBoundaryRecord? wordBoundary = !_rect.contains(localPosition) ? null : _getWordBoundaryAtPosition(position);
|
||||
final TextPosition targetPosition = _clampTextPosition(isEnd ? _updateSelectionEndEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd) : _updateSelectionStartEdgeByWord(wordBoundary, position, existingSelectionStart, existingSelectionEnd));
|
||||
|
||||
_setSelectionPosition(targetPosition, isEnd: isEnd);
|
||||
if (targetPosition.offset == range.end) {
|
||||
return SelectionResult.next;
|
||||
}
|
||||
|
||||
if (targetPosition.offset == range.start) {
|
||||
return SelectionResult.previous;
|
||||
}
|
||||
// TODO(chunhtai): The geometry information should not be used to determine
|
||||
// selection result. This is a workaround to RenderParagraph, where it does
|
||||
// not have a way to get accurate text length if its text is truncated due to
|
||||
// layout constraint.
|
||||
return SelectionUtils.getResultBasedOnRect(_rect, localPosition);
|
||||
}
|
||||
|
||||
TextPosition _clampTextPosition(TextPosition position) {
|
||||
// Affinity of range.end is upstream.
|
||||
if (position.offset > range.end ||
|
||||
@ -1497,6 +1705,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
||||
SelectionResult _handleClearSelection() {
|
||||
_textSelectionStart = null;
|
||||
_textSelectionEnd = null;
|
||||
_selectableContainsOriginWord = false;
|
||||
return SelectionResult.none;
|
||||
}
|
||||
|
||||
@ -1507,20 +1716,29 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
||||
}
|
||||
|
||||
SelectionResult _handleSelectWord(Offset globalPosition) {
|
||||
_selectableContainsOriginWord = true;
|
||||
|
||||
final TextPosition position = paragraph.getPositionForOffset(paragraph.globalToLocal(globalPosition));
|
||||
if (_positionIsWithinCurrentSelection(position)) {
|
||||
return SelectionResult.end;
|
||||
}
|
||||
final TextRange word = paragraph.getWordBoundary(position);
|
||||
assert(word.isNormalized);
|
||||
if (word.start < range.start && word.end < range.start) {
|
||||
final _WordBoundaryRecord wordBoundary = _getWordBoundaryAtPosition(position);
|
||||
if (wordBoundary.wordStart.offset < range.start && wordBoundary.wordEnd.offset < range.start) {
|
||||
return SelectionResult.previous;
|
||||
} else if (word.start > range.end && word.end > range.end) {
|
||||
} else if (wordBoundary.wordStart.offset > range.end && wordBoundary.wordEnd.offset > range.end) {
|
||||
return SelectionResult.next;
|
||||
}
|
||||
// Fragments are separated by placeholder span, the word boundary shouldn't
|
||||
// expand across fragments.
|
||||
assert(word.start >= range.start && word.end <= range.end);
|
||||
assert(wordBoundary.wordStart.offset >= range.start && wordBoundary.wordEnd.offset <= range.end);
|
||||
_textSelectionStart = wordBoundary.wordStart;
|
||||
_textSelectionEnd = wordBoundary.wordEnd;
|
||||
return SelectionResult.end;
|
||||
}
|
||||
|
||||
_WordBoundaryRecord _getWordBoundaryAtPosition(TextPosition position) {
|
||||
final TextRange word = paragraph.getWordBoundary(position);
|
||||
assert(word.isNormalized);
|
||||
late TextPosition start;
|
||||
late TextPosition end;
|
||||
if (position.offset > word.end) {
|
||||
@ -1529,9 +1747,7 @@ class _SelectableFragment with Selectable, ChangeNotifier implements TextLayoutM
|
||||
start = TextPosition(offset: word.start);
|
||||
end = TextPosition(offset: word.end, affinity: TextAffinity.upstream);
|
||||
}
|
||||
_textSelectionStart = start;
|
||||
_textSelectionEnd = end;
|
||||
return SelectionResult.end;
|
||||
return (wordStart: start, wordEnd: end);
|
||||
}
|
||||
|
||||
SelectionResult _handleDirectionallyExtendSelection(double horizontalBaseline, bool isExtent, SelectionExtendDirection movement) {
|
||||
|
@ -375,26 +375,44 @@ class SelectWordSelectionEvent extends SelectionEvent {
|
||||
///
|
||||
/// The [globalPosition] contains the new offset of the edge.
|
||||
///
|
||||
/// This event is dispatched when the framework detects [DragStartDetails] in
|
||||
/// The [granularity] contains the granularity that the selection edge should move by.
|
||||
/// Only [TextGranularity.character] and [TextGranularity.word] are currently supported.
|
||||
///
|
||||
/// This event is dispatched when the framework detects [TapDragStartDetails] in
|
||||
/// [SelectionArea]'s gesture recognizers for mouse devices, or the selection
|
||||
/// handles have been dragged to new locations.
|
||||
class SelectionEdgeUpdateEvent extends SelectionEvent {
|
||||
/// Creates a selection start edge update event.
|
||||
///
|
||||
/// The [globalPosition] contains the location of the selection start edge.
|
||||
///
|
||||
/// The [granularity] contains the granularity which the selection edge should move by.
|
||||
/// This value defaults to [TextGranularity.character].
|
||||
const SelectionEdgeUpdateEvent.forStart({
|
||||
required this.globalPosition
|
||||
}) : super._(SelectionEventType.startEdgeUpdate);
|
||||
required this.globalPosition,
|
||||
TextGranularity? granularity
|
||||
}) : granularity = granularity ?? TextGranularity.character, super._(SelectionEventType.startEdgeUpdate);
|
||||
|
||||
/// Creates a selection end edge update event.
|
||||
///
|
||||
/// The [globalPosition] contains the new location of the selection end edge.
|
||||
///
|
||||
/// The [granularity] contains the granularity which the selection edge should move by.
|
||||
/// This value defaults to [TextGranularity.character].
|
||||
const SelectionEdgeUpdateEvent.forEnd({
|
||||
required this.globalPosition
|
||||
}) : super._(SelectionEventType.endEdgeUpdate);
|
||||
required this.globalPosition,
|
||||
TextGranularity? granularity
|
||||
}) : granularity = granularity ?? TextGranularity.character, super._(SelectionEventType.endEdgeUpdate);
|
||||
|
||||
/// The new location of the selection edge.
|
||||
final Offset globalPosition;
|
||||
|
||||
/// The granularity for which the selection moves.
|
||||
///
|
||||
/// Only [TextGranularity.character] and [TextGranularity.word] are currently supported.
|
||||
///
|
||||
/// Defaults to [TextGranularity.character].
|
||||
final TextGranularity granularity;
|
||||
}
|
||||
|
||||
/// Extends the start or end of the selection by a given [TextGranularity].
|
||||
@ -686,7 +704,7 @@ class SelectionGeometry {
|
||||
|
||||
/// The geometry information of a selection point.
|
||||
@immutable
|
||||
class SelectionPoint {
|
||||
class SelectionPoint with Diagnosticable {
|
||||
/// Creates a selection point object.
|
||||
///
|
||||
/// All properties must not be null.
|
||||
@ -730,6 +748,14 @@ class SelectionPoint {
|
||||
handleType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DiagnosticsProperty<Offset>('localPosition', localPosition));
|
||||
properties.add(DoubleProperty('lineHeight', lineHeight));
|
||||
properties.add(EnumProperty<TextSelectionHandleType>('handleType', handleType));
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of selection handle to be displayed.
|
||||
|
@ -1177,11 +1177,11 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
|
||||
if (event.type == SelectionEventType.endEdgeUpdate) {
|
||||
_currentDragEndRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition);
|
||||
final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
|
||||
event = SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset);
|
||||
event = SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset, granularity: event.granularity);
|
||||
} else {
|
||||
_currentDragStartRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition);
|
||||
final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
|
||||
event = SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset);
|
||||
event = SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset, granularity: event.granularity);
|
||||
}
|
||||
final SelectionResult result = super.handleSelectionEdgeUpdate(event);
|
||||
|
||||
@ -1430,6 +1430,9 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
|
||||
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
|
||||
final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
|
||||
selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset));
|
||||
// Make sure we track that we have synthesized a start event for this selectable,
|
||||
// so we don't synthesize events unnecessarily.
|
||||
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
|
||||
}
|
||||
final double? previousEndRecord = _selectableEndEdgeUpdateRecords[selectable];
|
||||
if (_currentDragEndRelatedToOrigin != null &&
|
||||
@ -1438,6 +1441,9 @@ class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionCont
|
||||
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
|
||||
final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
|
||||
selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset));
|
||||
// Make sure we track that we have synthesized an end event for this selectable,
|
||||
// so we don't synthesize events unnecessarily.
|
||||
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -422,15 +422,46 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
|
||||
// gestures.
|
||||
|
||||
// Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
|
||||
// which can grow to be infinitely large, to a value between 1 and the supported
|
||||
// max consecutive tap count. The value that the raw count is converted to is
|
||||
// based on the default observed behavior on the native platforms.
|
||||
//
|
||||
// This method should be used in all instances when details.consecutiveTapCount
|
||||
// would be used.
|
||||
static int _getEffectiveConsecutiveTapCount(int rawCount) {
|
||||
const int maxConsecutiveTap = 2;
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
case TargetPlatform.linux:
|
||||
// From observation, these platforms reset their tap count to 0 when
|
||||
// the number of consecutive taps exceeds the max consecutive tap supported.
|
||||
// For example on Debian Linux with GTK, when going past a triple click,
|
||||
// on the fourth click the selection is moved to the precise click
|
||||
// position, on the fifth click the word at the position is selected, and
|
||||
// on the sixth click the paragraph at the position is selected.
|
||||
return rawCount <= maxConsecutiveTap ? rawCount : (rawCount % maxConsecutiveTap == 0 ? maxConsecutiveTap : rawCount % maxConsecutiveTap);
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
case TargetPlatform.windows:
|
||||
// From observation, these platforms either hold their tap count at the max
|
||||
// consecutive tap supported. For example on macOS, when going past a triple
|
||||
// click, the selection should be retained at the paragraph that was first
|
||||
// selected on triple click.
|
||||
return min(rawCount, maxConsecutiveTap);
|
||||
}
|
||||
}
|
||||
|
||||
void _initMouseGestureRecognizer() {
|
||||
_gestureRecognizers[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
|
||||
() => PanGestureRecognizer(debugOwner:this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
|
||||
(PanGestureRecognizer instance) {
|
||||
_gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
|
||||
() => TapAndPanGestureRecognizer(debugOwner:this, supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse }),
|
||||
(TapAndPanGestureRecognizer instance) {
|
||||
instance
|
||||
..onDown = _startNewMouseSelectionGesture
|
||||
..onStart = _handleMouseDragStart
|
||||
..onUpdate = _handleMouseDragUpdate
|
||||
..onEnd = _handleMouseDragEnd
|
||||
..onTapDown = _startNewMouseSelectionGesture
|
||||
..onDragStart = _handleMouseDragStart
|
||||
..onDragUpdate = _handleMouseDragUpdate
|
||||
..onDragEnd = _handleMouseDragEnd
|
||||
..onCancel = _clearSelection
|
||||
..dragStartBehavior = DragStartBehavior.down;
|
||||
},
|
||||
@ -449,18 +480,36 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
);
|
||||
}
|
||||
|
||||
void _startNewMouseSelectionGesture(DragDownDetails details) {
|
||||
widget.focusNode.requestFocus();
|
||||
hideToolbar();
|
||||
_clearSelection();
|
||||
void _startNewMouseSelectionGesture(TapDragDownDetails details) {
|
||||
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
|
||||
case 1:
|
||||
widget.focusNode.requestFocus();
|
||||
hideToolbar();
|
||||
_clearSelection();
|
||||
case 2:
|
||||
_selectWordAt(offset: details.globalPosition);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMouseDragStart(DragStartDetails details) {
|
||||
_selectStartTo(offset: details.globalPosition);
|
||||
void _handleMouseDragStart(TapDragStartDetails details) {
|
||||
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
|
||||
case 1:
|
||||
_selectStartTo(offset: details.globalPosition);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMouseDragUpdate(DragUpdateDetails details) {
|
||||
_selectEndTo(offset: details.globalPosition, continuous: true);
|
||||
void _handleMouseDragUpdate(TapDragUpdateDetails details) {
|
||||
switch (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount)) {
|
||||
case 1:
|
||||
_selectEndTo(offset: details.globalPosition, continuous: true);
|
||||
case 2:
|
||||
_selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMouseDragEnd(TapDragEndDetails details) {
|
||||
_finalizeSelection();
|
||||
_updateSelectedContentIfNeeded();
|
||||
}
|
||||
|
||||
void _updateSelectedContentIfNeeded() {
|
||||
@ -470,11 +519,6 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMouseDragEnd(DragEndDetails details) {
|
||||
_finalizeSelection();
|
||||
_updateSelectedContentIfNeeded();
|
||||
}
|
||||
|
||||
void _handleTouchLongPressStart(LongPressStartDetails details) {
|
||||
HapticFeedback.selectionClick();
|
||||
widget.focusNode.requestFocus();
|
||||
@ -563,7 +607,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
/// If the selectable subtree returns a [SelectionResult.pending], this method
|
||||
/// continues to send [SelectionEdgeUpdateEvent]s every frame until the result
|
||||
/// is not pending or users end their gestures.
|
||||
void _triggerSelectionEndEdgeUpdate() {
|
||||
void _triggerSelectionEndEdgeUpdate({TextGranularity? textGranularity}) {
|
||||
// This method can be called when the drag is not in progress. This can
|
||||
// happen if the child scrollable returns SelectionResult.pending, and
|
||||
// the selection area scheduled a selection update for the next frame, but
|
||||
@ -572,14 +616,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
return;
|
||||
}
|
||||
if (_selectable?.dispatchSelectionEvent(
|
||||
SelectionEdgeUpdateEvent.forEnd(globalPosition: _selectionEndPosition!)) == SelectionResult.pending) {
|
||||
SelectionEdgeUpdateEvent.forEnd(globalPosition: _selectionEndPosition!, granularity: textGranularity)) == SelectionResult.pending) {
|
||||
_scheduledSelectionEndEdgeUpdate = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
|
||||
if (!_scheduledSelectionEndEdgeUpdate) {
|
||||
return;
|
||||
}
|
||||
_scheduledSelectionEndEdgeUpdate = false;
|
||||
_triggerSelectionEndEdgeUpdate();
|
||||
_triggerSelectionEndEdgeUpdate(textGranularity: textGranularity);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -617,7 +661,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
/// If the selectable subtree returns a [SelectionResult.pending], this method
|
||||
/// continues to send [SelectionEdgeUpdateEvent]s every frame until the result
|
||||
/// is not pending or users end their gestures.
|
||||
void _triggerSelectionStartEdgeUpdate() {
|
||||
void _triggerSelectionStartEdgeUpdate({TextGranularity? textGranularity}) {
|
||||
// This method can be called when the drag is not in progress. This can
|
||||
// happen if the child scrollable returns SelectionResult.pending, and
|
||||
// the selection area scheduled a selection update for the next frame, but
|
||||
@ -626,14 +670,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
return;
|
||||
}
|
||||
if (_selectable?.dispatchSelectionEvent(
|
||||
SelectionEdgeUpdateEvent.forStart(globalPosition: _selectionStartPosition!)) == SelectionResult.pending) {
|
||||
SelectionEdgeUpdateEvent.forStart(globalPosition: _selectionStartPosition!, granularity: textGranularity)) == SelectionResult.pending) {
|
||||
_scheduledSelectionStartEdgeUpdate = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
|
||||
if (!_scheduledSelectionStartEdgeUpdate) {
|
||||
return;
|
||||
}
|
||||
_scheduledSelectionStartEdgeUpdate = false;
|
||||
_triggerSelectionStartEdgeUpdate();
|
||||
_triggerSelectionStartEdgeUpdate(textGranularity: textGranularity);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -845,20 +889,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
///
|
||||
/// The `offset` is in global coordinates.
|
||||
///
|
||||
/// Provide the `textGranularity` if the selection should not move by the default
|
||||
/// [TextGranularity.character]. Only [TextGranularity.character] and
|
||||
/// [TextGranularity.word] are currently supported.
|
||||
///
|
||||
/// See also:
|
||||
/// * [_selectStartTo], which sets or updates selection start 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 _selectEndTo({required Offset offset, bool continuous = false}) {
|
||||
void _selectEndTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
|
||||
if (!continuous) {
|
||||
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset));
|
||||
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: offset, granularity: textGranularity));
|
||||
return;
|
||||
}
|
||||
if (_selectionEndPosition != offset) {
|
||||
_selectionEndPosition = offset;
|
||||
_triggerSelectionEndEdgeUpdate();
|
||||
_triggerSelectionEndEdgeUpdate(textGranularity: textGranularity);
|
||||
}
|
||||
}
|
||||
|
||||
@ -880,20 +928,24 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
||||
///
|
||||
/// The `offset` is in global coordinates.
|
||||
///
|
||||
/// Provide the `textGranularity` if the selection should not move by the default
|
||||
/// [TextGranularity.character]. Only [TextGranularity.character] and
|
||||
/// [TextGranularity.word] are currently supported.
|
||||
///
|
||||
/// See also:
|
||||
/// * [_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 _selectStartTo({required Offset offset, bool continuous = false}) {
|
||||
void _selectStartTo({required Offset offset, bool continuous = false, TextGranularity? textGranularity}) {
|
||||
if (!continuous) {
|
||||
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset));
|
||||
_selectable?.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: offset, granularity: textGranularity));
|
||||
return;
|
||||
}
|
||||
if (_selectionStartPosition != offset) {
|
||||
_selectionStartPosition = offset;
|
||||
_triggerSelectionStartEdgeUpdate();
|
||||
_triggerSelectionStartEdgeUpdate(textGranularity: textGranularity);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,6 +157,7 @@ void main() {
|
||||
|
||||
// Backwards selection.
|
||||
await gesture.down(textOffsetToPosition(paragraph, 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(content, isNull);
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph, 0));
|
||||
await gesture.up();
|
||||
|
@ -4,6 +4,7 @@
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@ -106,6 +107,89 @@ void main() {
|
||||
await gesture.up();
|
||||
});
|
||||
|
||||
testWidgets('mouse can select multiple widgets on double-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.pumpAndSettle();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
|
||||
await tester.pump();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
|
||||
|
||||
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph2, 4));
|
||||
// Should select the rest of paragraph 1.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
|
||||
|
||||
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 3'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph3, 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: 4));
|
||||
|
||||
await gesture.up();
|
||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||
|
||||
testWidgets('mouse can select multiple widgets on double-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.pumpAndSettle();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
|
||||
await tester.pump();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 5));
|
||||
|
||||
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Item 1'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph2, 5) + const Offset(0, 5));
|
||||
// Should select the rest of paragraph 1.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||
expect(paragraph2.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();
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
|
@ -37,7 +37,7 @@ void main() {
|
||||
});
|
||||
|
||||
group('SelectableRegion', () {
|
||||
testWidgets('mouse selection sends correct events', (WidgetTester tester) async {
|
||||
testWidgets('mouse selection single click sends correct events', (WidgetTester tester) async {
|
||||
final UniqueKey spy = UniqueKey();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -53,6 +53,7 @@ void main() {
|
||||
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
|
||||
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await tester.pumpAndSettle();
|
||||
renderSelectionSpy.events.clear();
|
||||
|
||||
await gesture.moveTo(const Offset(200.0, 100.0));
|
||||
@ -74,6 +75,34 @@ void main() {
|
||||
await gesture.up();
|
||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410.
|
||||
|
||||
testWidgets('mouse double click sends select-word event', (WidgetTester tester) async {
|
||||
final UniqueKey spy = UniqueKey();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
focusNode: FocusNode(),
|
||||
selectionControls: materialTextSelectionControls,
|
||||
child: SelectionSpy(key: spy),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
|
||||
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
renderSelectionSpy.events.clear();
|
||||
await gesture.down(const Offset(200.0, 200.0));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
expect(renderSelectionSpy.events.length, 1);
|
||||
expect(renderSelectionSpy.events[0], isA<SelectWordSelectionEvent>());
|
||||
final SelectWordSelectionEvent selectionEvent = renderSelectionSpy.events[0] as SelectWordSelectionEvent;
|
||||
expect(selectionEvent.globalPosition, const Offset(200.0, 200.0));
|
||||
});
|
||||
|
||||
testWidgets('Does not crash when using Navigator pages', (WidgetTester tester) async {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/119776
|
||||
await tester.pumpWidget(
|
||||
@ -249,6 +278,7 @@ void main() {
|
||||
final RenderSelectionSpy renderSelectionSpy = tester.renderObject<RenderSelectionSpy>(find.byKey(spy));
|
||||
final TestGesture gesture = await tester.startGesture(const Offset(200.0, 200.0), kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await tester.pumpAndSettle();
|
||||
expect(renderSelectionSpy.events.length, 1);
|
||||
expect(renderSelectionSpy.events[0], isA<ClearSelectionEvent>());
|
||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/102410.
|
||||
@ -506,6 +536,7 @@ void main() {
|
||||
// Start a new drag.
|
||||
await gesture.up();
|
||||
await gesture.down(textOffsetToPosition(paragraph, 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(paragraph.selections.isEmpty, isTrue);
|
||||
|
||||
// Selecting across line should select to the end.
|
||||
@ -516,6 +547,224 @@ void main() {
|
||||
await gesture.up();
|
||||
});
|
||||
|
||||
testWidgets('mouse can select word-by-word on double click drag', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
focusNode: FocusNode(),
|
||||
selectionControls: materialTextSelectionControls,
|
||||
child: const Center(
|
||||
child: Text('How are you'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you'), 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.pumpAndSettle();
|
||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph, 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 4));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph, 4));
|
||||
await tester.pump();
|
||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph, 7));
|
||||
await tester.pump();
|
||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 8));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph, 8));
|
||||
await tester.pump();
|
||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
|
||||
|
||||
// Check backward selection.
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph, 1));
|
||||
await tester.pump();
|
||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
|
||||
|
||||
// Start a new double-click drag.
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
await gesture.down(textOffsetToPosition(paragraph, 5));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
expect(paragraph.selections.isEmpty, isTrue);
|
||||
await tester.pump(kDoubleTapTimeout);
|
||||
|
||||
// Double-click.
|
||||
await gesture.down(textOffsetToPosition(paragraph, 5));
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
await gesture.down(textOffsetToPosition(paragraph, 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(paragraph.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
|
||||
|
||||
// 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: 4, extentOffset: 11));
|
||||
await gesture.up();
|
||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||
|
||||
testWidgets('mouse can select multiple widgets on double click drag', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
focusNode: FocusNode(),
|
||||
selectionControls: materialTextSelectionControls,
|
||||
child: const Column(
|
||||
children: <Widget>[
|
||||
Text('How are you?'),
|
||||
Text('Good, and you?'),
|
||||
Text('Fine, thank you.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), 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.pumpAndSettle();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
|
||||
await tester.pump();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
|
||||
|
||||
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
|
||||
// Should select the rest of paragraph 1.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||
|
||||
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
|
||||
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
|
||||
|
||||
await gesture.up();
|
||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||
|
||||
testWidgets('mouse can select multiple widgets on double click drag and return to origin word', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
focusNode: FocusNode(),
|
||||
selectionControls: materialTextSelectionControls,
|
||||
child: const Column(
|
||||
children: <Widget>[
|
||||
Text('How are you?'),
|
||||
Text('Good, and you?'),
|
||||
Text('Fine, thank you.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), 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.pumpAndSettle();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 3));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
|
||||
await tester.pump();
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
|
||||
|
||||
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
|
||||
// Should select the rest of paragraph 1.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||
|
||||
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph3, 6));
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 14));
|
||||
expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 11));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
|
||||
// Should clear the selection on paragraph 3.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 12));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 6));
|
||||
expect(paragraph3.selections.isEmpty, true);
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph1, 4));
|
||||
// Should clear the selection on paragraph 2.
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 0, extentOffset: 7));
|
||||
expect(paragraph2.selections.isEmpty, true);
|
||||
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 double click drag', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SelectableRegion(
|
||||
focusNode: FocusNode(),
|
||||
selectionControls: materialTextSelectionControls,
|
||||
child: const Column(
|
||||
children: <Widget>[
|
||||
Text('How are you?'),
|
||||
Text('Good, and you?'),
|
||||
Text('Fine, thank you.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final RenderParagraph paragraph3 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText)));
|
||||
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph3, 10), kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await tester.pump();
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
await gesture.down(textOffsetToPosition(paragraph3, 10));
|
||||
await tester.pumpAndSettle();
|
||||
expect(paragraph3.selections[0], const TextSelection(baseOffset: 6, extentOffset: 11));
|
||||
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph3, 4));
|
||||
await tester.pump();
|
||||
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 4));
|
||||
|
||||
final RenderParagraph paragraph2 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph2, 5));
|
||||
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 0));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 5));
|
||||
|
||||
final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph1, 6));
|
||||
expect(paragraph3.selections[0], const TextSelection(baseOffset: 11, extentOffset: 0));
|
||||
expect(paragraph2.selections[0], const TextSelection(baseOffset: 14, extentOffset: 0));
|
||||
expect(paragraph1.selections[0], const TextSelection(baseOffset: 12, extentOffset: 4));
|
||||
|
||||
await gesture.up();
|
||||
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/125582.
|
||||
|
||||
testWidgets('mouse can select multiple widgets', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -2416,6 +2665,7 @@ void main() {
|
||||
|
||||
// Backwards selection.
|
||||
await gesture.down(textOffsetToPosition(paragraph, 3));
|
||||
await tester.pumpAndSettle();
|
||||
expect(content, isNull);
|
||||
await gesture.moveTo(textOffsetToPosition(paragraph, 0));
|
||||
await gesture.up();
|
||||
|
Loading…
x
Reference in New Issue
Block a user