fix: SelectableRegion should only finalize selection after changing (#159698)
There are some cases where selection behavior varies on a given platform by the pointer device, for example dragging to select changes on mobile platforms depending on whether a mouse or a touch is used. When a mouse is used a user can drag to select normally, when a touch is used the selection does not change on mobile platforms when dragging. Before this change at the end of a touch drag users would still be notified the selection was finalized even though nothing changed and they had not previously received a `changing` notification. After this change the selection is no longer finalized unless the `SelectableRegionSelectionStatus` was previously in a `changing` state. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. --------- Co-authored-by: Renzo Olivares <roliv@google.com>
This commit is contained in:
parent
129a35a5e1
commit
d620ec9274
@ -491,7 +491,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
// the Flutter application.
|
// the Flutter application.
|
||||||
clearSelection();
|
clearSelection();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
@ -541,6 +541,14 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _finalizeSelectableRegionStatus() {
|
||||||
|
if (_selectionStatusNotifier.value != SelectableRegionSelectionStatus.changing) {
|
||||||
|
// Don't finalize the selection again if it is not currently changing.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
||||||
|
}
|
||||||
|
|
||||||
// Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
|
// Converts the details.consecutiveTapCount from a TapAndDrag*Details object,
|
||||||
// which can grow to be infinitely large, to a value between 1 and the supported
|
// 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
|
// max consecutive tap count. The value that the raw count is converted to is
|
||||||
@ -808,20 +816,22 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleMouseDragEnd(TapDragEndDetails details) {
|
void _handleMouseDragEnd(TapDragEndDetails details) {
|
||||||
final bool isPointerPrecise = _lastPointerDeviceKind != null && _lastPointerDeviceKind == PointerDeviceKind.mouse;
|
assert(_lastPointerDeviceKind != null);
|
||||||
|
final bool isPointerPrecise = _isPrecisePointerDevice(_lastPointerDeviceKind!);
|
||||||
|
// On mobile platforms like android, fuchsia, and iOS, a drag gesture will
|
||||||
|
// only show the selection overlay when the drag has finished and the pointer
|
||||||
|
// device kind is not precise, for example at the end of a double tap + drag
|
||||||
|
// to select on native iOS.
|
||||||
|
final bool shouldShowSelectionOverlayOnMobile = !isPointerPrecise;
|
||||||
switch (defaultTargetPlatform) {
|
switch (defaultTargetPlatform) {
|
||||||
case TargetPlatform.android:
|
case TargetPlatform.android:
|
||||||
case TargetPlatform.fuchsia:
|
case TargetPlatform.fuchsia:
|
||||||
if (!isPointerPrecise) {
|
if (shouldShowSelectionOverlayOnMobile) {
|
||||||
// On Android, a drag gesture will only show the selection overlay when
|
|
||||||
// the drag has finished and the pointer device kind is not precise.
|
|
||||||
_showHandles();
|
_showHandles();
|
||||||
_showToolbar();
|
_showToolbar();
|
||||||
}
|
}
|
||||||
case TargetPlatform.iOS:
|
case TargetPlatform.iOS:
|
||||||
if (!isPointerPrecise) {
|
if (shouldShowSelectionOverlayOnMobile) {
|
||||||
// On iOS, a drag gesture will only show the selection toolbar when
|
|
||||||
// the drag has finished and the pointer device kind is not precise.
|
|
||||||
_showToolbar();
|
_showToolbar();
|
||||||
}
|
}
|
||||||
case TargetPlatform.macOS:
|
case TargetPlatform.macOS:
|
||||||
@ -832,7 +842,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
}
|
}
|
||||||
_finalizeSelection();
|
_finalizeSelection();
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleMouseTapUp(TapDragUpDetails details) {
|
void _handleMouseTapUp(TapDragUpDetails details) {
|
||||||
@ -856,12 +866,10 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
hideToolbar();
|
hideToolbar();
|
||||||
_collapseSelectionAt(offset: details.globalPosition);
|
_collapseSelectionAt(offset: details.globalPosition);
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
|
||||||
case TargetPlatform.macOS:
|
case TargetPlatform.macOS:
|
||||||
case TargetPlatform.linux:
|
case TargetPlatform.linux:
|
||||||
case TargetPlatform.windows:
|
case TargetPlatform.windows:
|
||||||
// On desktop platforms the selection is set on tap down.
|
// On desktop platforms the selection is set on tap down.
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
final bool isPointerPrecise = _isPrecisePointerDevice(details.kind);
|
final bool isPointerPrecise = _isPrecisePointerDevice(details.kind);
|
||||||
@ -878,7 +886,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
if (!isPointerPrecise) {
|
if (!isPointerPrecise) {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
// Double tap on iOS web only triggers when a drag begins after the double tap.
|
// Double tap on iOS web only triggers when a drag begins after the double tap.
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
// On iOS, a double tap will only show the selection toolbar after
|
// On iOS, a double tap will only show the selection toolbar after
|
||||||
// the following tap up when the pointer device kind is not precise.
|
// the following tap up when the pointer device kind is not precise.
|
||||||
@ -891,24 +899,8 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
// on a double click.
|
// on a double click.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
|
||||||
case 3:
|
|
||||||
switch (defaultTargetPlatform) {
|
|
||||||
case TargetPlatform.android:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
if (_isPrecisePointerDevice(details.kind)) {
|
|
||||||
// Triple tap on static text is only supported on mobile
|
|
||||||
// platforms using a precise pointer device, so we should
|
|
||||||
// only update the SelectableRegionSelectionStatus in that case.
|
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
|
||||||
}
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_finalizeSelectableRegionStatus();
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -946,7 +938,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
void _handleTouchLongPressEnd(LongPressEndDetails details) {
|
void _handleTouchLongPressEnd(LongPressEndDetails details) {
|
||||||
_finalizeSelection();
|
_finalizeSelection();
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
_showToolbar();
|
_showToolbar();
|
||||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
_showHandles();
|
_showHandles();
|
||||||
@ -1007,7 +999,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
// Restore _lastSecondaryTapDownPosition since it may be cleared if a user
|
// Restore _lastSecondaryTapDownPosition since it may be cleared if a user
|
||||||
// accesses contextMenuAnchors.
|
// accesses contextMenuAnchors.
|
||||||
_lastSecondaryTapDownPosition = details.globalPosition;
|
_lastSecondaryTapDownPosition = details.globalPosition;
|
||||||
@ -1064,7 +1056,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
}
|
}
|
||||||
_finalizeSelection();
|
_finalizeSelection();
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopSelectionEndEdgeUpdate() {
|
void _stopSelectionEndEdgeUpdate() {
|
||||||
@ -1545,7 +1537,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
);
|
);
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
double? _directionalHorizontalBaseline;
|
double? _directionalHorizontalBaseline;
|
||||||
@ -1569,7 +1561,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
);
|
);
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// [TextSelectionDelegate] overrides.
|
// [TextSelectionDelegate] overrides.
|
||||||
@ -1602,7 +1594,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
case TargetPlatform.fuchsia:
|
case TargetPlatform.fuchsia:
|
||||||
clearSelection();
|
clearSelection();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
case TargetPlatform.iOS:
|
case TargetPlatform.iOS:
|
||||||
hideToolbar(false);
|
hideToolbar(false);
|
||||||
case TargetPlatform.linux:
|
case TargetPlatform.linux:
|
||||||
@ -1633,7 +1625,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
case TargetPlatform.fuchsia:
|
case TargetPlatform.fuchsia:
|
||||||
clearSelection();
|
clearSelection();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
case TargetPlatform.iOS:
|
case TargetPlatform.iOS:
|
||||||
hideToolbar(false);
|
hideToolbar(false);
|
||||||
case TargetPlatform.linux:
|
case TargetPlatform.linux:
|
||||||
@ -1735,7 +1727,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
}
|
}
|
||||||
_updateSelectedContentIfNeeded();
|
_updateSelectedContentIfNeeded();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated(
|
||||||
@ -1747,7 +1739,7 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
|
|||||||
_copy();
|
_copy();
|
||||||
clearSelection();
|
clearSelection();
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
|
||||||
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized;
|
_finalizeSelectableRegionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated(
|
||||||
@ -3246,6 +3238,9 @@ final class _SelectableRegionSelectionStatusNotifier extends ChangeNotifier impl
|
|||||||
/// Listeners are notified even if the value did not change.
|
/// Listeners are notified even if the value did not change.
|
||||||
@protected
|
@protected
|
||||||
set value(SelectableRegionSelectionStatus newStatus) {
|
set value(SelectableRegionSelectionStatus newStatus) {
|
||||||
|
assert(newStatus == SelectableRegionSelectionStatus.finalized && value == SelectableRegionSelectionStatus.changing
|
||||||
|
|| newStatus == SelectableRegionSelectionStatus.changing,
|
||||||
|
'Attempting to finalize the selection when it is already finalized.');
|
||||||
_selectableRegionSelectionStatus = newStatus;
|
_selectableRegionSelectionStatus = newStatus;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
@ -1431,6 +1431,87 @@ void main() {
|
|||||||
await gesture.up();
|
await gesture.up();
|
||||||
}, variant: TargetPlatformVariant.mobile());
|
}, variant: TargetPlatformVariant.mobile());
|
||||||
|
|
||||||
|
testWidgets('mouse drag finalizes the selection', (WidgetTester tester) async {
|
||||||
|
SelectableRegionSelectionStatus? selectionStatus;
|
||||||
|
final GlobalKey textKey = GlobalKey();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
key: textKey,
|
||||||
|
'How are you',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(textKey.currentContext, isNotNull);
|
||||||
|
final ValueListenable<SelectableRegionSelectionStatus>? selectionStatusNotifier = SelectableRegionSelectionStatusScope.maybeOf(textKey.currentContext!);
|
||||||
|
void onSelectionStatusChange() {
|
||||||
|
selectionStatus = selectionStatusNotifier?.value;
|
||||||
|
}
|
||||||
|
selectionStatusNotifier?.addListener(onSelectionStatusChange);
|
||||||
|
addTearDown(() {
|
||||||
|
selectionStatusNotifier?.removeListener(onSelectionStatusChange);
|
||||||
|
});
|
||||||
|
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.moveTo(textOffsetToPosition(paragraph, 4));
|
||||||
|
await tester.pump();
|
||||||
|
expect(selectionStatus, SelectableRegionSelectionStatus.changing);
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(paragraph.selections.length, 1);
|
||||||
|
expect(selectionStatus, SelectableRegionSelectionStatus.finalized);
|
||||||
|
}, variant: TargetPlatformVariant.all());
|
||||||
|
|
||||||
|
testWidgets('touch drag does not finalize selection on mobile platforms', (WidgetTester tester) async {
|
||||||
|
SelectableRegionSelectionStatus? selectionStatus;
|
||||||
|
final GlobalKey textKey = GlobalKey();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: SelectableRegion(
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
key: textKey,
|
||||||
|
'How are you',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(textKey.currentContext, isNotNull);
|
||||||
|
final ValueListenable<SelectableRegionSelectionStatus>? selectionStatusNotifier = SelectableRegionSelectionStatusScope.maybeOf(textKey.currentContext!);
|
||||||
|
void onSelectionStatusChange() {
|
||||||
|
selectionStatus = selectionStatusNotifier?.value;
|
||||||
|
}
|
||||||
|
selectionStatusNotifier?.addListener(onSelectionStatusChange);
|
||||||
|
addTearDown(() {
|
||||||
|
selectionStatusNotifier?.removeListener(onSelectionStatusChange);
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
addTearDown(gesture.removePointer);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await gesture.moveTo(textOffsetToPosition(paragraph, 4));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(paragraph.selections.length, 0);
|
||||||
|
expect(selectionStatus, isNull);
|
||||||
|
}, variant: TargetPlatformVariant.mobile());
|
||||||
|
|
||||||
testWidgets('mouse can select word-by-word on double click drag', (WidgetTester tester) async {
|
testWidgets('mouse can select word-by-word on double click drag', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user