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

[New feature]Introduce iOS multi-touch drag behavior (#141355)

Fixes #38926

This patch implements the iOS behavior pointed out by @dkwingsmt at #38926 , which is also consistent with the performance of my settings application on the iPhone.

### iOS behavior (horizontal or vertical drag)

## Algorithm
When dragging: delta(combined) = max(i of n that are positive) delta(i) - max(i of n that are negative) delta(i)
It means that, if two fingers are moving +50 and +10 respectively, it will move +50; if they're moving at +50 and -10 respectively, it will move +40.

~~TODO~~
~~Write some test cases~~
parent 1da48594
......@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'button.dart';
......@@ -492,6 +493,9 @@ class CupertinoScrollBehavior extends ScrollBehavior {
}
return const BouncingScrollPhysics();
}
@override
MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) => MultitouchDragStrategy.averageBoundaryPointers;
}
class _CupertinoAppState extends State<CupertinoApp> {
......
......@@ -51,24 +51,53 @@ enum DragStartBehavior {
/// 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.
/// between all the 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
/// If the tracked pointer is released, the first accepted 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, and the result is computed from
/// the boundary pointers.
///
/// The scrolling offset is determined by the maximum deltas of both directions.
///
/// If the user is dragging with 3 pointers at the same time, each having
/// \[+10, +20, +33\] pixels of offset, the recognizer will report a delta of 33 pixels.
///
/// If the user is dragging with 5 pointers at the same time, each having
/// \[+10, +20, +33, -1, -12\] pixels of offset, the recognizer will report a
/// delta of (+33) + (-12) = 21 pixels.
///
/// The panning [PanGestureRecognizer] offset is the average of all pointers.
///
/// If the user is dragging with 3 pointers at the same time, each having
/// \[+10, +50, -30\] pixels of offset in one direction (horizontal or vertical),
/// the recognizer will report a delta of (10 + 50 -30) / 3 = 10 pixels in this direction.
///
/// This is the behavior typically seen on iOS.
averageBoundaryPointers,
/// 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.
///
/// If the user is dragging with 3 pointers at the same time, each having
/// \[+10, +20, +33\] pixels of offset, the recognizer will report a delta
/// of 10 + 20 + 33 = 63 pixels.
///
/// If the user is dragging with 5 pointers at the same time, each having
/// \[+10, +20, +33, -1, -12\] pixels of offset, the recognizer will report
/// a delta of 10 + 20 + 33 - 1 - 12 = 50 pixels.
sumAllPointers,
}
......
......@@ -9,6 +9,7 @@ import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'framework.dart';
import 'media_query.dart';
import 'scroll_configuration.dart';
export 'package:flutter/gestures.dart' show
DragDownDetails,
......@@ -1020,6 +1021,7 @@ class GestureDetector extends StatelessWidget {
Widget build(BuildContext context) {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context);
final ScrollBehavior configuration = ScrollConfiguration.of(context);
if (onTapDown != null ||
onTapUp != null ||
......@@ -1137,6 +1139,7 @@ class GestureDetector extends StatelessWidget {
..onEnd = onVerticalDragEnd
..onCancel = onVerticalDragCancel
..dragStartBehavior = dragStartBehavior
..multitouchDragStrategy = configuration.getMultitouchDragStrategy(context)
..gestureSettings = gestureSettings
..supportedDevices = supportedDevices;
},
......@@ -1158,6 +1161,7 @@ class GestureDetector extends StatelessWidget {
..onEnd = onHorizontalDragEnd
..onCancel = onHorizontalDragCancel
..dragStartBehavior = dragStartBehavior
..multitouchDragStrategy = configuration.getMultitouchDragStrategy(context)
..gestureSettings = gestureSettings
..supportedDevices = supportedDevices;
},
......@@ -1179,6 +1183,7 @@ class GestureDetector extends StatelessWidget {
..onEnd = onPanEnd
..onCancel = onPanCancel
..dragStartBehavior = dragStartBehavior
..multitouchDragStrategy = configuration.getMultitouchDragStrategy(context)
..gestureSettings = gestureSettings
..supportedDevices = supportedDevices;
},
......
......@@ -110,8 +110,20 @@ class ScrollBehavior {
/// {@macro flutter.gestures.monodrag.DragGestureRecognizer.multitouchDragStrategy}
///
/// By default, [MultitouchDragStrategy.latestPointer] is configured to
/// create drag gestures for all platforms.
MultitouchDragStrategy get multitouchDragStrategy => MultitouchDragStrategy.latestPointer;
/// create drag gestures for non-Apple platforms, and
/// [MultitouchDragStrategy.averageBoundaryPointers] for Apple platforms.
MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) {
switch (getPlatform(context)) {
case TargetPlatform.macOS:
case TargetPlatform.iOS:
return MultitouchDragStrategy.averageBoundaryPointers;
case TargetPlatform.linux:
case TargetPlatform.windows:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
return MultitouchDragStrategy.latestPointer;
}
}
/// A set of [LogicalKeyboardKey]s that, when any or all are pressed in
/// combination with a [PointerDeviceKind.mouse] pointer scroll event, will
......@@ -253,12 +265,11 @@ class _WrappedScrollBehavior implements ScrollBehavior {
this.scrollbars = true,
this.overscroll = true,
Set<PointerDeviceKind>? dragDevices,
MultitouchDragStrategy? multitouchDragStrategy,
this.multitouchDragStrategy,
Set<LogicalKeyboardKey>? pointerAxisModifiers,
this.physics,
this.platform,
}) : _dragDevices = dragDevices,
_multitouchDragStrategy = multitouchDragStrategy,
_pointerAxisModifiers = pointerAxisModifiers;
final ScrollBehavior delegate;
......@@ -267,17 +278,19 @@ class _WrappedScrollBehavior implements ScrollBehavior {
final ScrollPhysics? physics;
final TargetPlatform? platform;
final Set<PointerDeviceKind>? _dragDevices;
final MultitouchDragStrategy? _multitouchDragStrategy;
final MultitouchDragStrategy? multitouchDragStrategy;
final Set<LogicalKeyboardKey>? _pointerAxisModifiers;
@override
Set<PointerDeviceKind> get dragDevices => _dragDevices ?? delegate.dragDevices;
@override
MultitouchDragStrategy get multitouchDragStrategy => _multitouchDragStrategy ?? delegate.multitouchDragStrategy;
Set<LogicalKeyboardKey> get pointerAxisModifiers => _pointerAxisModifiers ?? delegate.pointerAxisModifiers;
@override
Set<LogicalKeyboardKey> get pointerAxisModifiers => _pointerAxisModifiers ?? delegate.pointerAxisModifiers;
MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) {
return multitouchDragStrategy ?? delegate.getMultitouchDragStrategy(context);
}
@override
Widget buildOverscrollIndicator(BuildContext context, Widget child, ScrollableDetails details) {
......
......@@ -758,7 +758,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..multitouchDragStrategy = _configuration.multitouchDragStrategy
..multitouchDragStrategy = _configuration.getMultitouchDragStrategy(context)
..gestureSettings = _mediaQueryGestureSettings
..supportedDevices = _configuration.dragDevices;
},
......@@ -780,7 +780,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..multitouchDragStrategy = _configuration.multitouchDragStrategy
..multitouchDragStrategy = _configuration.getMultitouchDragStrategy(context)
..gestureSettings = _mediaQueryGestureSettings
..supportedDevices = _configuration.dragDevices;
},
......
......@@ -4,6 +4,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
......@@ -352,6 +353,24 @@ void main() {
expect(ScrollConfiguration.of(capturedContext).runtimeType, CupertinoScrollBehavior);
});
testWidgets('CupertinoApp has correct default multitouchDragStrategy', (WidgetTester tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
CupertinoApp(
home: Builder(
builder: (BuildContext context) {
capturedContext = context;
return const Placeholder();
},
),
),
);
final ScrollBehavior scrollBehavior = ScrollConfiguration.of(capturedContext);
expect(scrollBehavior.runtimeType, CupertinoScrollBehavior);
expect(scrollBehavior.getMultitouchDragStrategy(capturedContext), MultitouchDragStrategy.averageBoundaryPointers);
});
testWidgets('A ScrollBehavior can be set for CupertinoApp', (WidgetTester tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
......
......@@ -416,7 +416,7 @@ void main() {
),
);
await tester.drag(find.text('10'), const Offset(0.0, 32.0), touchSlopY: 0, warnIfMissed: false); // see top of file
await tester.drag(find.text('10'), const Offset(0.0, 32.0), pointer: 1, touchSlopY: 0, warnIfMissed: false); // see top of file
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
......@@ -438,7 +438,7 @@ void main() {
),
);
await tester.drag(find.text('9'), const Offset(0.0, 32.0), touchSlopY: 0, warnIfMissed: false); // see top of file
await tester.drag(find.text('9'), const Offset(0.0, 32.0), pointer: 1, touchSlopY: 0, warnIfMissed: false); // see top of file
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
......@@ -1246,14 +1246,14 @@ void main() {
),
);
await tester.drag(find.text('27'), const Offset(0.0, -32.0), touchSlopY: 0.0, warnIfMissed: false); // see top of file
await tester.drag(find.text('27'), const Offset(0.0, -32.0), pointer: 1, touchSlopY: 0.0, warnIfMissed: false); // see top of file
await tester.pump();
expect(
date,
DateTime(2018, 2, 28),
);
await tester.drag(find.text('28'), const Offset(0.0, -32.0), touchSlopY: 0.0, warnIfMissed: false); // see top of file
await tester.drag(find.text('28'), const Offset(0.0, -32.0), pointer: 1, touchSlopY: 0.0, warnIfMissed: false); // see top of file
await tester.pump(); // Once to trigger the post frame animate call.
// Callback doesn't transiently go into invalid dates.
......
......@@ -343,7 +343,7 @@ void main() {
);
// Drag it by a bit but not enough to move to the next item.
await tester.drag(find.text('10'), const Offset(0.0, 30.0), touchSlopY: 0.0, warnIfMissed: false); // has an IgnorePointer
await tester.drag(find.text('10'), const Offset(0.0, 30.0), pointer: 1, touchSlopY: 0.0, warnIfMissed: false); // has an IgnorePointer
// The item that was in the center now moved a bit.
expect(
......@@ -360,7 +360,7 @@ void main() {
expect(selectedItems.isEmpty, true);
// Drag it by enough to move to the next item.
await tester.drag(find.text('10'), const Offset(0.0, 70.0), touchSlopY: 0.0, warnIfMissed: false); // has an IgnorePointer
await tester.drag(find.text('10'), const Offset(0.0, 70.0), pointer: 1, touchSlopY: 0.0, warnIfMissed: false); // has an IgnorePointer
await tester.pumpAndSettle();
......
......@@ -705,7 +705,7 @@ void main() {
),
);
await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0);
await tester.drag(find.text('0'), const Offset(0.0, 150.0), pointer: 1, touchSlopY: 0.0);
await tester.pump();
expect(mockHelper.invocations, contains(const RefreshTaskInvocation()));
......@@ -748,7 +748,7 @@ void main() {
// Start another drag by an amount that would have been enough to
// trigger another refresh if it were in the right state.
await tester.drag(find.text('0'), const Offset(0.0, 150.0), touchSlopY: 0.0, warnIfMissed: false);
await tester.drag(find.text('0'), const Offset(0.0, 150.0), pointer: 1, touchSlopY: 0.0, warnIfMissed: false);
await tester.pump();
// Instead, it's still in the done state because the sliver never
......@@ -779,7 +779,7 @@ void main() {
);
// Start another drag. It's now in drag mode.
await tester.drag(find.text('0'), const Offset(0.0, 40.0), touchSlopY: 0.0);
await tester.drag(find.text('0'), const Offset(0.0, 40.0), pointer: 1, touchSlopY: 0.0);
await tester.pump();
expect(mockHelper.invocations, contains(matchesBuilder(
refreshState: RefreshIndicatorMode.drag,
......
......@@ -3070,6 +3070,7 @@ void main() {
await tester.drag(
find.byType(CustomScrollView),
const Offset(0.0, -20.0),
pointer: 1,
);
await tester.pumpAndSettle();
final NestedScrollViewState nestedScrollView = tester.state<NestedScrollViewState>(
......
......@@ -154,7 +154,7 @@ void main() {
expect(find.byType(GlowingOverscrollIndicator), findsOneWidget);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('ScrollBehavior multitouchDragStrategy test', (WidgetTester tester) async {
testWidgets('ScrollBehavior multitouchDragStrategy test - 1', (WidgetTester tester) async {
const ScrollBehavior behavior1 = ScrollBehavior();
final ScrollBehavior behavior2 = const ScrollBehavior().copyWith(
multitouchDragStrategy: MultitouchDragStrategy.sumAllPointers
......@@ -201,11 +201,11 @@ void main() {
await gesture2.moveBy(const Offset(0, -50));
await tester.pump();
// The default multitouchDragStrategy should be MultitouchDragStrategy.latestPointer.
// Only the latest active pointer be tracked.
// The default multitouchDragStrategy is 'latestPointer' or 'averageBoundaryPointers,
// the received delta should be 50.0.
expect(controller.position.pixels, 50.0);
// Change to MultitouchDragStrategy.sumAllPointers.
// Change to sumAllPointers.
await tester.pumpWidget(buildFrame(behavior2));
await gesture1.moveBy(const Offset(0, -50));
......@@ -218,6 +218,147 @@ void main() {
expect(controller.position.pixels, 50.0 + 50.0 + 50.0);
}, variant: TargetPlatformVariant.all());
testWidgets('ScrollBehavior multitouchDragStrategy test (non-Apple platforms) - 2', (WidgetTester tester) async {
const ScrollBehavior behavior1 = ScrollBehavior();
final ScrollBehavior behavior2 = const ScrollBehavior().copyWith(
multitouchDragStrategy: MultitouchDragStrategy.averageBoundaryPointers
);
final ScrollController controller = ScrollController();
late BuildContext capturedContext;
addTearDown(() => controller.dispose());
Widget buildFrame(ScrollBehavior behavior) {
return Directionality(
textDirection: TextDirection.ltr,
child: ScrollConfiguration(
behavior: behavior,
child: Builder(
builder: (BuildContext context) {
capturedContext = context;
return 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, -40));
await tester.pump();
// The default multitouchDragStrategy is latestPointer.
// Only the latest active pointer be tracked.
final ScrollBehavior scrollBehavior = ScrollConfiguration.of(capturedContext);
expect(scrollBehavior.getMultitouchDragStrategy(capturedContext), MultitouchDragStrategy.latestPointer);
expect(controller.position.pixels, 40.0);
// Change to averageBoundaryPointers.
await tester.pumpWidget(buildFrame(behavior2));
await gesture1.moveBy(const Offset(0, -70));
await tester.pump();
await gesture2.moveBy(const Offset(0, -60));
await tester.pump();
expect(controller.position.pixels, 40.0 + 70.0);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.linux, TargetPlatform.fuchsia, TargetPlatform.windows }));
testWidgets('ScrollBehavior multitouchDragStrategy test (Apple platforms) - 3', (WidgetTester tester) async {
const ScrollBehavior behavior1 = ScrollBehavior();
final ScrollBehavior behavior2 = const ScrollBehavior().copyWith(
multitouchDragStrategy: MultitouchDragStrategy.latestPointer
);
final ScrollController controller = ScrollController();
late BuildContext capturedContext;
addTearDown(() => controller.dispose());
Widget buildFrame(ScrollBehavior behavior) {
return Directionality(
textDirection: TextDirection.ltr,
child: ScrollConfiguration(
behavior: behavior,
child: Builder(
builder: (BuildContext context) {
capturedContext = context;
return 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, -40));
await tester.pump();
await gesture2.moveBy(const Offset(0, -50));
await tester.pump();
// The default multitouchDragStrategy is averageBoundaryPointers.
final ScrollBehavior scrollBehavior = ScrollConfiguration.of(capturedContext);
expect(scrollBehavior.getMultitouchDragStrategy(capturedContext), MultitouchDragStrategy.averageBoundaryPointers);
expect(controller.position.pixels, 50.0);
// Change to latestPointer.
await tester.pumpWidget(buildFrame(behavior2));
await gesture1.moveBy(const Offset(0, -50));
await tester.pump();
await gesture2.moveBy(const Offset(0, -40));
await tester.pump();
expect(controller.position.pixels, 50.0 + 40.0);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
group('ScrollBehavior configuration is maintained over multiple copies', () {
testWidgets('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