Unverified Commit d49dcaa6 authored by xubaolin's avatar xubaolin Committed by GitHub

Introduce multi-touch drag strategies for `DragGestureRecognizer` (#136708)

Fixes #11884

As #38926 pointed out, the current Flutter implementation of multi-finger drag behavior is different from iOS and Android.
This change introduces the `MultitouchDragStrategy` attribute, which implements the Android behavior and can be controlled through `ScrollBehavior`, while retaining the ability to extend iOS behavior in the future.
parent cae47c46
......@@ -74,6 +74,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
DragGestureRecognizer({
super.debugOwner,
this.dragStartBehavior = DragStartBehavior.start,
this.multitouchDragStrategy = MultitouchDragStrategy.latestPointer,
this.velocityTrackerBuilder = _defaultBuilder,
this.onlyAcceptDragOnThreshold = false,
super.supportedDevices,
......@@ -111,6 +112,26 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
/// position (510.0, 500.0).
DragStartBehavior dragStartBehavior;
/// {@template flutter.gestures.monodrag.DragGestureRecognizer.multitouchDragStrategy}
/// Configure the multi-finger drag strategy on multi-touch devices.
///
/// If set to [MultitouchDragStrategy.latestPointer], the drag gesture recognizer
/// will only track the latest active (accepted by this recognizer) pointer, which
/// appears to be only one finger dragging.
///
/// If set to [MultitouchDragStrategy.sumAllPointers],
/// all active pointers will be tracked together and the scrolling offset
/// is the sum of the offsets of all active pointers
/// {@endtemplate}
///
/// By default, the strategy is [MultitouchDragStrategy.latestPointer].
///
/// See also:
///
/// * [MultitouchDragStrategy], which defines two different drag strategies for
/// multi-finger drag.
MultitouchDragStrategy multitouchDragStrategy;
/// A pointer has contacted the screen with a primary button and might begin
/// to move.
///
......@@ -359,6 +380,17 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
_addPointer(event);
}
bool _shouldTrackMoveEvent(int pointer) {
final bool result;
switch (multitouchDragStrategy) {
case MultitouchDragStrategy.sumAllPointers:
result = true;
case MultitouchDragStrategy.latestPointer:
result = _acceptedActivePointers.length <= 1 || pointer == _acceptedActivePointers.last;
}
return result;
}
@override
void handleEvent(PointerEvent event) {
assert(_state != _DragState.ready);
......@@ -380,7 +412,8 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
_giveUpPointer(event.pointer);
return;
}
if (event is PointerMoveEvent || event is PointerPanZoomUpdateEvent) {
if ((event is PointerMoveEvent || event is PointerPanZoomUpdateEvent)
&& _shouldTrackMoveEvent(event.pointer)) {
final Offset delta = (event is PointerMoveEvent) ? event.delta : (event as PointerPanZoomUpdateEvent).panDelta;
final Offset localDelta = (event is PointerMoveEvent) ? event.localDelta : (event as PointerPanZoomUpdateEvent).localPanDelta;
final Offset position = (event is PointerMoveEvent) ? event.position : (event.position + (event as PointerPanZoomUpdateEvent).pan);
......@@ -419,7 +452,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
}
}
final Set<int> _acceptedActivePointers = <int>{};
final List<int> _acceptedActivePointers = <int>[];
@override
void acceptGesture(int pointer) {
......
......@@ -48,6 +48,30 @@ enum DragStartBehavior {
start,
}
/// Configuration of multi-finger drag strategy on multi-touch devices.
///
/// When dragging with only one finger, there's no difference in behavior
/// between the two settings.
///
/// Used by [DragGestureRecognizer.multitouchDragStrategy].
enum MultitouchDragStrategy {
/// Only the latest active pointer is tracked by the recognizer.
///
/// If the tracked pointer is released, the latest of the remaining active
/// pointers will continue to be tracked.
///
/// This is the behavior typically seen on Android.
latestPointer,
/// All active pointers will be tracked together. The scrolling offset
/// is the sum of the offsets of all active pointers.
///
/// When a [Scrollable] drives scrolling by this drag strategy, the scrolling
/// speed will double or triple, depending on how many fingers are dragging
/// at the same time.
sumAllPointers,
}
/// Signature for `allowedButtonsFilter` in [GestureRecognizer].
/// Used to filter the input buttons of incoming pointer events.
/// The parameter `buttons` comes from [PointerEvent.buttons].
......
......@@ -77,6 +77,7 @@ class ScrollBehavior {
bool? scrollbars,
bool? overscroll,
Set<PointerDeviceKind>? dragDevices,
MultitouchDragStrategy? multitouchDragStrategy,
Set<LogicalKeyboardKey>? pointerAxisModifiers,
ScrollPhysics? physics,
TargetPlatform? platform,
......@@ -86,6 +87,7 @@ class ScrollBehavior {
scrollbars: scrollbars ?? true,
overscroll: overscroll ?? true,
dragDevices: dragDevices,
multitouchDragStrategy: multitouchDragStrategy,
pointerAxisModifiers: pointerAxisModifiers,
physics: physics,
platform: platform,
......@@ -105,6 +107,12 @@ class ScrollBehavior {
/// impossible to select text in scrollable containers and is not recommended.
Set<PointerDeviceKind> get dragDevices => _kTouchLikeDeviceTypes;
/// {@macro flutter.gestures.monodrag.DragGestureRecognizer.multitouchDragStrategy}
///
/// By default, [MultitouchDragStrategy.latestPointer] is configured to
/// create drag gestures for all platforms.
MultitouchDragStrategy get multitouchDragStrategy => MultitouchDragStrategy.latestPointer;
/// A set of [LogicalKeyboardKey]s that, when any or all are pressed in
/// combination with a [PointerDeviceKind.mouse] pointer scroll event, will
/// flip the axes of the scroll input.
......@@ -245,10 +253,12 @@ class _WrappedScrollBehavior implements ScrollBehavior {
this.scrollbars = true,
this.overscroll = true,
Set<PointerDeviceKind>? dragDevices,
MultitouchDragStrategy? multitouchDragStrategy,
Set<LogicalKeyboardKey>? pointerAxisModifiers,
this.physics,
this.platform,
}) : _dragDevices = dragDevices,
_multitouchDragStrategy = multitouchDragStrategy,
_pointerAxisModifiers = pointerAxisModifiers;
final ScrollBehavior delegate;
......@@ -257,11 +267,15 @@ class _WrappedScrollBehavior implements ScrollBehavior {
final ScrollPhysics? physics;
final TargetPlatform? platform;
final Set<PointerDeviceKind>? _dragDevices;
final MultitouchDragStrategy? _multitouchDragStrategy;
final Set<LogicalKeyboardKey>? _pointerAxisModifiers;
@override
Set<PointerDeviceKind> get dragDevices => _dragDevices ?? delegate.dragDevices;
@override
MultitouchDragStrategy get multitouchDragStrategy => _multitouchDragStrategy ?? delegate.multitouchDragStrategy;
@override
Set<LogicalKeyboardKey> get pointerAxisModifiers => _pointerAxisModifiers ?? delegate.pointerAxisModifiers;
......@@ -286,6 +300,7 @@ class _WrappedScrollBehavior implements ScrollBehavior {
bool? scrollbars,
bool? overscroll,
Set<PointerDeviceKind>? dragDevices,
MultitouchDragStrategy? multitouchDragStrategy,
Set<LogicalKeyboardKey>? pointerAxisModifiers,
ScrollPhysics? physics,
TargetPlatform? platform,
......@@ -294,6 +309,7 @@ class _WrappedScrollBehavior implements ScrollBehavior {
scrollbars: scrollbars ?? this.scrollbars,
overscroll: overscroll ?? this.overscroll,
dragDevices: dragDevices ?? this.dragDevices,
multitouchDragStrategy: multitouchDragStrategy ?? this.multitouchDragStrategy,
pointerAxisModifiers: pointerAxisModifiers ?? this.pointerAxisModifiers,
physics: physics ?? this.physics,
platform: platform ?? this.platform,
......@@ -316,6 +332,7 @@ class _WrappedScrollBehavior implements ScrollBehavior {
|| oldDelegate.scrollbars != scrollbars
|| oldDelegate.overscroll != overscroll
|| !setEquals<PointerDeviceKind>(oldDelegate.dragDevices, dragDevices)
|| oldDelegate.multitouchDragStrategy != multitouchDragStrategy
|| !setEquals<LogicalKeyboardKey>(oldDelegate.pointerAxisModifiers, pointerAxisModifiers)
|| oldDelegate.physics != physics
|| oldDelegate.platform != platform
......
......@@ -762,6 +762,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..multitouchDragStrategy = _configuration.multitouchDragStrategy
..gestureSettings = _mediaQueryGestureSettings
..supportedDevices = _configuration.dragDevices;
},
......@@ -783,6 +784,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..multitouchDragStrategy = _configuration.multitouchDragStrategy
..gestureSettings = _mediaQueryGestureSettings
..supportedDevices = _configuration.dragDevices;
},
......
......@@ -466,11 +466,15 @@ void main() {
expect(updateDelta, const Offset(20.0, 0.0));
});
testGesture('Drag with multiple pointers in down behavior', (GestureTester tester) {
testGesture('Drag with multiple pointers in down behavior - sumAllPointers', (GestureTester tester) {
final HorizontalDragGestureRecognizer drag1 =
HorizontalDragGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down;
HorizontalDragGestureRecognizer()
..dragStartBehavior = DragStartBehavior.down
..multitouchDragStrategy = MultitouchDragStrategy.sumAllPointers;
final VerticalDragGestureRecognizer drag2 =
VerticalDragGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down;
VerticalDragGestureRecognizer()
..dragStartBehavior = DragStartBehavior.down
..multitouchDragStrategy = MultitouchDragStrategy.sumAllPointers;
addTearDown(() => drag1.dispose);
addTearDown(() => drag2.dispose);
......@@ -507,11 +511,18 @@ void main() {
tester.route(down6);
log.add('-d');
// Check all active pointers can trigger 'drag1-update'.
tester.route(pointer5.move(const Offset(0.0, 100.0)));
log.add('-e');
tester.route(pointer5.move(const Offset(70.0, 70.0)));
log.add('-f');
tester.route(pointer6.move(const Offset(0.0, 100.0)));
log.add('-g');
tester.route(pointer6.move(const Offset(70.0, 70.0)));
log.add('-h');
tester.route(pointer5.up());
tester.route(pointer6.up());
......@@ -532,7 +543,108 @@ void main() {
'-e',
'drag1-update',
'-f',
'drag1-end',
'drag1-update',
'-g',
'drag1-update',
'-h',
'drag1-end'
]);
});
testGesture('Drag with multiple pointers in down behavior - default', (GestureTester tester) {
final HorizontalDragGestureRecognizer drag1 =
HorizontalDragGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down;
final VerticalDragGestureRecognizer drag2 =
VerticalDragGestureRecognizer() ..dragStartBehavior = DragStartBehavior.down;
addTearDown(() => drag1.dispose);
addTearDown(() => drag2.dispose);
final List<String> log = <String>[];
drag1.onDown = (_) { log.add('drag1-down'); };
drag1.onStart = (_) { log.add('drag1-start'); };
drag1.onUpdate = (_) { log.add('drag1-update'); };
drag1.onEnd = (_) { log.add('drag1-end'); };
drag1.onCancel = () { log.add('drag1-cancel'); };
drag2.onDown = (_) { log.add('drag2-down'); };
drag2.onStart = (_) { log.add('drag2-start'); };
drag2.onUpdate = (_) { log.add('drag2-update'); };
drag2.onEnd = (_) { log.add('drag2-end'); };
drag2.onCancel = () { log.add('drag2-cancel'); };
final TestPointer pointer5 = TestPointer(5);
final PointerDownEvent down5 = pointer5.down(const Offset(10.0, 10.0));
drag1.addPointer(down5);
drag2.addPointer(down5);
tester.closeArena(5);
tester.route(down5);
log.add('-a');
tester.route(pointer5.move(const Offset(100.0, 0.0)));
log.add('-b');
tester.route(pointer5.move(const Offset(50.0, 50.0)));
log.add('-c');
final TestPointer pointer6 = TestPointer(6);
final PointerDownEvent down6 = pointer6.down(const Offset(20.0, 20.0));
drag1.addPointer(down6);
drag2.addPointer(down6);
tester.closeArena(6);
tester.route(down6);
log.add('-d');
// Current latest active pointer is pointer6.
// Should not trigger the drag1-update.
tester.route(pointer5.move(const Offset(0.0, 100.0)));
log.add('-e');
tester.route(pointer5.move(const Offset(70.0, 70.0)));
log.add('-f');
// Latest active pointer can trigger the drag1-update.
tester.route(pointer6.move(const Offset(0.0, 100.0)));
log.add('-g');
tester.route(pointer6.move(const Offset(70.0, 70.0)));
log.add('-h');
// Release the latest active pointer.
tester.route(pointer6.up());
log.add('-i');
// Current latest active pointer is pointer5.
// Latest active pointer can trigger the drag1-update.
tester.route(pointer5.move(const Offset(0.0, 100.0)));
log.add('-j');
tester.route(pointer5.move(const Offset(70.0, 70.0)));
log.add('-k');
tester.route(pointer5.up());
expect(log, <String>[
'drag1-down',
'drag2-down',
'-a',
'drag2-cancel',
'drag1-start',
'drag1-update',
'-b',
'drag1-update',
'-c',
'drag2-down',
'drag2-cancel',
'-d',
'-e',
'-f',
'drag1-update',
'-g',
'drag1-update',
'-h',
'-i',
'drag1-update',
'-j',
'drag1-update',
'-k',
'drag1-end'
]);
});
......
......@@ -155,6 +155,70 @@ void main() {
expect(find.byType(GlowingOverscrollIndicator), findsOneWidget);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgetsWithLeakTracking('ScrollBehavior multitouchDragStrategy test', (WidgetTester tester) async {
const ScrollBehavior behavior1 = ScrollBehavior();
final ScrollBehavior behavior2 = const ScrollBehavior().copyWith(
multitouchDragStrategy: MultitouchDragStrategy.sumAllPointers
);
final ScrollController controller = ScrollController();
addTearDown(() => controller.dispose());
Widget buildFrame(ScrollBehavior behavior) {
return Directionality(
textDirection: TextDirection.ltr,
child: ScrollConfiguration(
behavior: behavior,
child: ListView(
controller: controller,
children: const <Widget>[
SizedBox(
height: 1000.0,
width: 1000.0,
child: Text('I Love Flutter!'),
),
],
),
),
);
}
await tester.pumpWidget(buildFrame(behavior1));
expect(controller.position.pixels, 0.0);
final Offset listLocation = tester.getCenter(find.byType(ListView));
final TestGesture gesture1 = await tester.createGesture(pointer: 1);
await gesture1.down(listLocation);
await tester.pump();
final TestGesture gesture2 = await tester.createGesture(pointer: 2);
await gesture2.down(listLocation);
await tester.pump();
await gesture1.moveBy(const Offset(0, -50));
await tester.pump();
await gesture2.moveBy(const Offset(0, -50));
await tester.pump();
// The default multitouchDragStrategy should be MultitouchDragStrategy.latestPointer.
// Only the latest active pointer be tracked.
expect(controller.position.pixels, 50.0);
// Change to MultitouchDragStrategy.sumAllPointers.
await tester.pumpWidget(buildFrame(behavior2));
await gesture1.moveBy(const Offset(0, -50));
await tester.pump();
await gesture2.moveBy(const Offset(0, -50));
await tester.pump();
// All active pointers be tracked.
expect(controller.position.pixels, 50.0 + 50.0 + 50.0);
}, variant: TargetPlatformVariant.all());
group('ScrollBehavior configuration is maintained over multiple copies', () {
testWidgetsWithLeakTracking('dragDevices', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/91673
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment