Fix SelectionArea scrolling conflicts (#151138)

Fixes #150897
This commit is contained in:
Renzo Olivares 2024-07-18 14:50:20 -07:00 committed by GitHub
parent 6e877226bf
commit 93fd29455d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 189 additions and 21 deletions

View File

@ -535,15 +535,11 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
}
void _initMouseGestureRecognizer() {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
_gestureRecognizers[TapAndPanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndPanGestureRecognizer>(
() => TapAndPanGestureRecognizer(debugOwner:this),
() => TapAndPanGestureRecognizer(
debugOwner:this,
supportedDevices: <PointerDeviceKind>{ PointerDeviceKind.mouse },
),
(TapAndPanGestureRecognizer instance) {
instance
..onTapDown = _startNewMouseSelectionGesture
@ -556,9 +552,43 @@ class SelectableRegionState extends State<SelectableRegion> with TextSelectionDe
},
);
}
}
void _initTouchGestureRecognizer() {
// A [TapAndHorizontalDragGestureRecognizer] is used on non-precise pointer devices
// like PointerDeviceKind.touch so [SelectableRegion] gestures do not conflict with
// ancestor Scrollable gestures in common scenarios like a vertically scrolling list view.
_gestureRecognizers[TapAndHorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapAndHorizontalDragGestureRecognizer>(
() => TapAndHorizontalDragGestureRecognizer(
debugOwner:this,
supportedDevices: PointerDeviceKind.values.where((PointerDeviceKind device) {
return device != PointerDeviceKind.mouse;
}).toSet(),
),
(TapAndHorizontalDragGestureRecognizer instance) {
instance
// iOS does not provide a device specific touch slop
// unlike Android (~8.0), so the touch slop for a [Scrollable]
// always default to kTouchSlop which is 18.0. When
// [SelectableRegion] is the child of a horizontal
// scrollable that means the [SelectableRegion] will
// always win the gesture arena when competing with
// the ancestor scrollable because they both have
// the same touch slop threshold and the child receives
// the [PointerEvent] first. To avoid this conflict
// and ensure a smooth scrolling experience, on
// iOS the [TapAndHorizontalDragGestureRecognizer]
// will wait for all other gestures to lose before
// declaring victory.
..eagerVictoryOnDrag = defaultTargetPlatform != TargetPlatform.iOS
..onTapDown = _startNewMouseSelectionGesture
..onTapUp = _handleMouseTapUp
..onDragStart = _handleMouseDragStart
..onDragUpdate = _handleMouseDragUpdate
..onDragEnd = _handleMouseDragEnd
..onCancel = _clearSelection
..dragStartBehavior = DragStartBehavior.down;
},
);
_gestureRecognizers[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(debugOwner: this, supportedDevices: _kLongPressSelectionDevices),
(LongPressGestureRecognizer instance) {

View File

@ -312,6 +312,144 @@ void main() {
semantics.dispose();
});
testWidgets('Horizontal PageView beats SelectionArea child touch drag gestures on iOS', (WidgetTester tester) async {
final PageController pageController = PageController();
const String testValue = 'abc def ghi jkl mno pqr stu vwx yz';
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
addTearDown(pageController.dispose);
await tester.pumpWidget(
MaterialApp(
home: PageView(
controller: pageController,
children: <Widget>[
Center(
child: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Text(testValue),
),
),
const SizedBox(
height: 200.0,
child: Center(
child: Text('Page 2'),
),
),
],
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text(testValue), matching: find.byType(RichText)));
final Offset gPos = textOffsetToPosition(paragraph, testValue.indexOf('g'));
final Offset pPos = textOffsetToPosition(paragraph, testValue.indexOf('p'));
// A double tap + drag should take precendence over parent drags.
final TestGesture gesture = await tester.startGesture(gPos);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(gPos);
await tester.pumpAndSettle();
await gesture.moveTo(pPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(paragraph.selections, isNotEmpty);
expect(paragraph.selections[0], TextSelection(baseOffset: testValue.indexOf('g'), extentOffset: testValue.indexOf('p') + 3));
expect(pageController.page, isNotNull);
expect(pageController.page, 0.0);
// A horizontal drag directly on the SelectableRegion should move the page
// view to the next page.
final Rect selectableTextRect = tester.getRect(find.byType(SelectableRegion));
await tester.dragFrom(selectableTextRect.centerRight - const Offset(0.1, 0.0), const Offset(-500.0, 0.0));
await tester.pumpAndSettle();
expect(pageController.page, isNotNull);
expect(pageController.page, 1.0);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
skip: kIsWeb, // https://github.com/flutter/flutter/issues/125582.
);
testWidgets('Vertical PageView beats SelectionArea child touch drag gestures', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/150897.
final PageController pageController = PageController();
const String testValue = 'abc def ghi jkl mno pqr stu vwx yz';
final FocusNode focusNode = FocusNode();
addTearDown(focusNode.dispose);
addTearDown(pageController.dispose);
await tester.pumpWidget(
MaterialApp(
home: PageView(
scrollDirection: Axis.vertical,
controller: pageController,
children: <Widget>[
Center(
child: SelectableRegion(
focusNode: focusNode,
selectionControls: materialTextSelectionControls,
child: const Text(testValue),
),
),
const SizedBox(
height: 200.0,
child: Center(
child: Text('Page 2'),
),
),
],
),
),
);
final RenderParagraph paragraph = tester.renderObject<RenderParagraph>(find.descendant(of: find.text(testValue), matching: find.byType(RichText)));
final Offset gPos = textOffsetToPosition(paragraph, testValue.indexOf('g'));
final Offset pPos = textOffsetToPosition(paragraph, testValue.indexOf('p'));
// A double tap + drag should take precendence over parent drags.
final TestGesture gesture = await tester.startGesture(gPos);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.up();
await tester.pump();
await gesture.down(gPos);
await tester.pumpAndSettle();
await gesture.moveTo(pPos);
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(paragraph.selections, isNotEmpty);
expect(paragraph.selections[0], TextSelection(baseOffset: testValue.indexOf('g'), extentOffset: testValue.indexOf('p') + 3));
expect(pageController.page, isNotNull);
expect(pageController.page, 0.0);
// A vertical drag directly on the SelectableRegion should move the page
// view to the next page.
final Rect selectableTextRect = tester.getRect(find.byType(SelectableRegion));
// Simulate a pan by drag vertically first.
await gesture.down(selectableTextRect.center);
await tester.pump();
await gesture.moveTo(selectableTextRect.center + const Offset(0.0, -200.0));
// Introduce horizontal movement.
await gesture.moveTo(selectableTextRect.center + const Offset(5.0, -300.0));
await gesture.moveTo(selectableTextRect.center + const Offset(-10.0, -400.0));
// Continue dragging vertically.
await gesture.moveTo(selectableTextRect.center + const Offset(0.0, -500.0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
expect(pageController.page, isNotNull);
expect(pageController.page, 1.0);
},
variant: TargetPlatformVariant.mobile(),
skip: kIsWeb, // https://github.com/flutter/flutter/issues/125582.
);
testWidgets('mouse single-click selection collapses the selection', (WidgetTester tester) async {
final UniqueKey spy = UniqueKey();
final FocusNode focusNode = FocusNode();