From d49dcaa6978eaa99b6eb7cfc17b900b84c83e49b Mon Sep 17 00:00:00 2001 From: xubaolin Date: Thu, 30 Nov 2023 17:22:19 +0800 Subject: [PATCH] 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. --- .../flutter/lib/src/gestures/monodrag.dart | 37 +++++- .../flutter/lib/src/gestures/recognizer.dart | 24 ++++ .../lib/src/widgets/scroll_configuration.dart | 17 +++ .../flutter/lib/src/widgets/scrollable.dart | 2 + packages/flutter/test/gestures/drag_test.dart | 120 +++++++++++++++++- .../test/widgets/scroll_behavior_test.dart | 64 ++++++++++ 6 files changed, 258 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index 7904ab2be6..df98a4be1f 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart @@ -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 _acceptedActivePointers = {}; + final List _acceptedActivePointers = []; @override void acceptGesture(int pointer) { diff --git a/packages/flutter/lib/src/gestures/recognizer.dart b/packages/flutter/lib/src/gestures/recognizer.dart index ff6d60afc3..4e271208a9 100644 --- a/packages/flutter/lib/src/gestures/recognizer.dart +++ b/packages/flutter/lib/src/gestures/recognizer.dart @@ -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]. diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index 653e839b4d..599a2aa917 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart @@ -77,6 +77,7 @@ class ScrollBehavior { bool? scrollbars, bool? overscroll, Set? dragDevices, + MultitouchDragStrategy? multitouchDragStrategy, Set? 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 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? dragDevices, + MultitouchDragStrategy? multitouchDragStrategy, Set? 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? _dragDevices; + final MultitouchDragStrategy? _multitouchDragStrategy; final Set? _pointerAxisModifiers; @override Set get dragDevices => _dragDevices ?? delegate.dragDevices; + @override + MultitouchDragStrategy get multitouchDragStrategy => _multitouchDragStrategy ?? delegate.multitouchDragStrategy; + @override Set get pointerAxisModifiers => _pointerAxisModifiers ?? delegate.pointerAxisModifiers; @@ -286,6 +300,7 @@ class _WrappedScrollBehavior implements ScrollBehavior { bool? scrollbars, bool? overscroll, Set? dragDevices, + MultitouchDragStrategy? multitouchDragStrategy, Set? 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(oldDelegate.dragDevices, dragDevices) + || oldDelegate.multitouchDragStrategy != multitouchDragStrategy || !setEquals(oldDelegate.pointerAxisModifiers, pointerAxisModifiers) || oldDelegate.physics != physics || oldDelegate.platform != platform diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index dace4ff095..9b55e70716 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -762,6 +762,7 @@ class ScrollableState extends State 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 with TickerProviderStateMixin, R ..maxFlingVelocity = _physics?.maxFlingVelocity ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) ..dragStartBehavior = widget.dragStartBehavior + ..multitouchDragStrategy = _configuration.multitouchDragStrategy ..gestureSettings = _mediaQueryGestureSettings ..supportedDevices = _configuration.dragDevices; }, diff --git a/packages/flutter/test/gestures/drag_test.dart b/packages/flutter/test/gestures/drag_test.dart index ab231f11f1..6c3640b1cd 100644 --- a/packages/flutter/test/gestures/drag_test.dart +++ b/packages/flutter/test/gestures/drag_test.dart @@ -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 log = []; + 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, [ + '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' ]); }); diff --git a/packages/flutter/test/widgets/scroll_behavior_test.dart b/packages/flutter/test/widgets/scroll_behavior_test.dart index 087f80012a..e2674bb9bb 100644 --- a/packages/flutter/test/widgets/scroll_behavior_test.dart +++ b/packages/flutter/test/widgets/scroll_behavior_test.dart @@ -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 [ + 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