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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user