Skip to content

Commit c83237f

Browse files
authored
[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~~
1 parent 1da4859 commit c83237f

File tree

13 files changed

+1098
-35
lines changed

13 files changed

+1098
-35
lines changed

packages/flutter/lib/src/cupertino/app.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'package:flutter/gestures.dart';
56
import 'package:flutter/widgets.dart';
67

78
import 'button.dart';
@@ -492,6 +493,9 @@ class CupertinoScrollBehavior extends ScrollBehavior {
492493
}
493494
return const BouncingScrollPhysics();
494495
}
496+
497+
@override
498+
MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) => MultitouchDragStrategy.averageBoundaryPointers;
495499
}
496500

497501
class _CupertinoAppState extends State<CupertinoApp> {

packages/flutter/lib/src/gestures/monodrag.dart

Lines changed: 233 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:math';
6+
57
import 'package:flutter/foundation.dart';
8+
import 'package:flutter/scheduler.dart';
69

710
import 'constants.dart';
811
import 'drag_details.dart';
@@ -119,6 +122,9 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
119122
/// will only track the latest active (accepted by this recognizer) pointer, which
120123
/// appears to be only one finger dragging.
121124
///
125+
/// If set to [MultitouchDragStrategy.averageBoundaryPointers], all active
126+
/// pointers will be tracked, and the result is computed from the boundary pointers.
127+
///
122128
/// If set to [MultitouchDragStrategy.sumAllPointers],
123129
/// all active pointers will be tracked together and the scrolling offset
124130
/// is the sum of the offsets of all active pointers
@@ -128,7 +134,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
128134
///
129135
/// See also:
130136
///
131-
/// * [MultitouchDragStrategy], which defines two different drag strategies for
137+
/// * [MultitouchDragStrategy], which defines several different drag strategies for
132138
/// multi-finger drag.
133139
MultitouchDragStrategy multitouchDragStrategy;
134140

@@ -323,11 +329,27 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
323329

324330
Offset _getDeltaForDetails(Offset delta);
325331
double? _getPrimaryValueFromOffset(Offset value);
332+
333+
/// The axis (horizontal or vertical) corresponding to the primary drag direction.
334+
///
335+
/// The [PanGestureRecognizer] returns null.
336+
_DragDirection? _getPrimaryDragAxis() => null;
326337
bool _hasSufficientGlobalDistanceToAccept(PointerDeviceKind pointerDeviceKind, double? deviceTouchSlop);
327338
bool _hasDragThresholdBeenMet = false;
328339

329340
final Map<int, VelocityTracker> _velocityTrackers = <int, VelocityTracker>{};
330341

342+
// The move delta of each pointer before the next frame.
343+
//
344+
// The key is the pointer ID. It is cleared whenever a new batch of pointer events is detected.
345+
final Map<int, Offset> _moveDeltaBeforeFrame = <int, Offset>{};
346+
347+
// The timestamp of all events of the current frame.
348+
//
349+
// On a event with a different timestamp, the event is considered a new batch.
350+
Duration? _frameTimeStamp;
351+
Offset _lastUpdatedDeltaForPan = Offset.zero;
352+
331353
@override
332354
bool isPointerAllowed(PointerEvent event) {
333355
if (_initialButtons == null) {
@@ -389,13 +411,194 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
389411
final bool result;
390412
switch (multitouchDragStrategy) {
391413
case MultitouchDragStrategy.sumAllPointers:
414+
case MultitouchDragStrategy.averageBoundaryPointers:
392415
result = true;
393416
case MultitouchDragStrategy.latestPointer:
394-
result = _acceptedActivePointers.length <= 1 || pointer == _acceptedActivePointers.last;
417+
result = _activePointer == null || pointer == _activePointer;
395418
}
396419
return result;
397420
}
398421

422+
void _recordMoveDeltaForMultitouch(int pointer, Offset localDelta) {
423+
if (multitouchDragStrategy != MultitouchDragStrategy.averageBoundaryPointers) {
424+
assert(_frameTimeStamp == null);
425+
assert(_moveDeltaBeforeFrame.isEmpty);
426+
return;
427+
}
428+
429+
assert(_frameTimeStamp == SchedulerBinding.instance.currentSystemFrameTimeStamp);
430+
431+
if (_state != _DragState.accepted || localDelta == Offset.zero) {
432+
return;
433+
}
434+
435+
if (_moveDeltaBeforeFrame.containsKey(pointer)) {
436+
final Offset offset = _moveDeltaBeforeFrame[pointer]!;
437+
_moveDeltaBeforeFrame[pointer] = offset + localDelta;
438+
} else {
439+
_moveDeltaBeforeFrame[pointer] = localDelta;
440+
}
441+
}
442+
443+
double _getSumDelta({
444+
required int pointer,
445+
required bool positive,
446+
required _DragDirection axis,
447+
}) {
448+
double sum = 0.0;
449+
450+
if (!_moveDeltaBeforeFrame.containsKey(pointer)) {
451+
return sum;
452+
}
453+
454+
final Offset offset = _moveDeltaBeforeFrame[pointer]!;
455+
if (positive) {
456+
if (axis == _DragDirection.vertical) {
457+
sum = max(offset.dy, 0.0);
458+
} else {
459+
sum = max(offset.dx, 0.0);
460+
}
461+
} else {
462+
if (axis == _DragDirection.vertical) {
463+
sum = min(offset.dy, 0.0);
464+
} else {
465+
sum = min(offset.dx, 0.0);
466+
}
467+
}
468+
469+
return sum;
470+
}
471+
472+
int? _getMaxSumDeltaPointer({
473+
required bool positive,
474+
required _DragDirection axis,
475+
}) {
476+
if (_moveDeltaBeforeFrame.isEmpty) {
477+
return null;
478+
}
479+
480+
int? ret;
481+
double? max;
482+
double sum;
483+
for (final int pointer in _moveDeltaBeforeFrame.keys) {
484+
sum = _getSumDelta(pointer: pointer, positive: positive, axis: axis);
485+
if (ret == null) {
486+
ret = pointer;
487+
max = sum;
488+
} else {
489+
if (positive) {
490+
if (sum > max!) {
491+
ret = pointer;
492+
max = sum;
493+
}
494+
} else {
495+
if (sum < max!) {
496+
ret = pointer;
497+
max = sum;
498+
}
499+
}
500+
}
501+
}
502+
assert(ret != null);
503+
return ret;
504+
}
505+
506+
Offset _resolveLocalDeltaForMultitouch(int pointer, Offset localDelta) {
507+
if (multitouchDragStrategy != MultitouchDragStrategy.averageBoundaryPointers) {
508+
if (_frameTimeStamp != null) {
509+
_moveDeltaBeforeFrame.clear();
510+
_frameTimeStamp = null;
511+
_lastUpdatedDeltaForPan = Offset.zero;
512+
}
513+
return localDelta;
514+
}
515+
516+
final Duration currentSystemFrameTimeStamp = SchedulerBinding.instance.currentSystemFrameTimeStamp;
517+
if (_frameTimeStamp != currentSystemFrameTimeStamp) {
518+
_moveDeltaBeforeFrame.clear();
519+
_lastUpdatedDeltaForPan = Offset.zero;
520+
_frameTimeStamp = currentSystemFrameTimeStamp;
521+
}
522+
523+
assert(_frameTimeStamp == SchedulerBinding.instance.currentSystemFrameTimeStamp);
524+
525+
final _DragDirection? axis = _getPrimaryDragAxis();
526+
527+
if (_state != _DragState.accepted || localDelta == Offset.zero || (_moveDeltaBeforeFrame.isEmpty && axis != null)) {
528+
return localDelta;
529+
}
530+
531+
final double dx,dy;
532+
if (axis == _DragDirection.horizontal) {
533+
dx = _resolveDelta(pointer: pointer, axis: _DragDirection.horizontal, localDelta: localDelta);
534+
assert(dx.abs() <= localDelta.dx.abs());
535+
dy = 0.0;
536+
} else if (axis == _DragDirection.vertical) {
537+
dx = 0.0;
538+
dy = _resolveDelta(pointer: pointer, axis: _DragDirection.vertical, localDelta: localDelta);
539+
assert(dy.abs() <= localDelta.dy.abs());
540+
} else {
541+
final double averageX = _resolveDeltaForPanGesture(axis: _DragDirection.horizontal, localDelta: localDelta);
542+
final double averageY = _resolveDeltaForPanGesture(axis: _DragDirection.vertical, localDelta: localDelta);
543+
final Offset updatedDelta = Offset(averageX, averageY) - _lastUpdatedDeltaForPan;
544+
_lastUpdatedDeltaForPan = Offset(averageX, averageY);
545+
dx = updatedDelta.dx;
546+
dy = updatedDelta.dy;
547+
}
548+
549+
return Offset(dx, dy);
550+
}
551+
552+
double _resolveDelta({
553+
required int pointer,
554+
required _DragDirection axis,
555+
required Offset localDelta,
556+
}) {
557+
final bool positive = axis == _DragDirection.horizontal ? localDelta.dx > 0 : localDelta.dy > 0;
558+
final double delta = axis == _DragDirection.horizontal ? localDelta.dx : localDelta.dy;
559+
final int? maxSumDeltaPointer = _getMaxSumDeltaPointer(positive: positive, axis: axis);
560+
assert(maxSumDeltaPointer != null);
561+
562+
if (maxSumDeltaPointer == pointer) {
563+
return delta;
564+
} else {
565+
final double maxSumDelta = _getSumDelta(pointer: maxSumDeltaPointer!, positive: positive, axis: axis);
566+
final double curPointerSumDelta = _getSumDelta(pointer: pointer, positive: positive, axis: axis);
567+
if (positive) {
568+
if (curPointerSumDelta + delta > maxSumDelta) {
569+
return curPointerSumDelta + delta - maxSumDelta;
570+
} else {
571+
return 0.0;
572+
}
573+
} else {
574+
if (curPointerSumDelta + delta < maxSumDelta) {
575+
return curPointerSumDelta + delta - maxSumDelta;
576+
} else {
577+
return 0.0;
578+
}
579+
}
580+
}
581+
}
582+
583+
double _resolveDeltaForPanGesture({
584+
required _DragDirection axis,
585+
required Offset localDelta,
586+
}) {
587+
final double delta = axis == _DragDirection.horizontal ? localDelta.dx : localDelta.dy;
588+
final int pointerCount = _acceptedActivePointers.length;
589+
assert(pointerCount >= 1);
590+
591+
double sum = delta;
592+
for (final Offset offset in _moveDeltaBeforeFrame.values) {
593+
if (axis == _DragDirection.horizontal) {
594+
sum += offset.dx;
595+
} else {
596+
sum += offset.dy;
597+
}
598+
}
599+
return sum / pointerCount;
600+
}
601+
399602
@override
400603
void handleEvent(PointerEvent event) {
401604
assert(_state != _DragState.ready);
@@ -424,6 +627,7 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
424627
final Offset position = (event is PointerMoveEvent) ? event.position : (event.position + (event as PointerPanZoomUpdateEvent).pan);
425628
final Offset localPosition = (event is PointerMoveEvent) ? event.localPosition : (event.localPosition + (event as PointerPanZoomUpdateEvent).localPan);
426629
_finalPosition = OffsetPair(local: localPosition, global: position);
630+
final Offset resolvedDelta = _resolveLocalDeltaForMultitouch(event.pointer, localDelta);
427631
switch (_state) {
428632
case _DragState.ready || _DragState.possible:
429633
_pendingDragOffset += OffsetPair(local: localDelta, global: delta);
@@ -447,24 +651,32 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
447651
case _DragState.accepted:
448652
_checkUpdate(
449653
sourceTimeStamp: event.timeStamp,
450-
delta: _getDeltaForDetails(localDelta),
451-
primaryDelta: _getPrimaryValueFromOffset(localDelta),
654+
delta: _getDeltaForDetails(resolvedDelta),
655+
primaryDelta: _getPrimaryValueFromOffset(resolvedDelta),
452656
globalPosition: position,
453657
localPosition: localPosition,
454658
);
455659
}
660+
_recordMoveDeltaForMultitouch(event.pointer, localDelta);
456661
}
457662
if (event case PointerUpEvent() || PointerCancelEvent() || PointerPanZoomEndEvent()) {
458663
_giveUpPointer(event.pointer);
459664
}
460665
}
461666

462667
final List<int> _acceptedActivePointers = <int>[];
668+
// This value is used when the multitouch strategy is `latestPointer`,
669+
// it keeps track of the last accepted pointer. If this active pointer
670+
// leave up, it will be set to the first accepted pointer.
671+
// Refer to the implementation of Android `RecyclerView`(line 3846):
672+
// https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-master-dev/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/RecyclerView.java
673+
int? _activePointer;
463674

464675
@override
465676
void acceptGesture(int pointer) {
466677
assert(!_acceptedActivePointers.contains(pointer));
467678
_acceptedActivePointers.add(pointer);
679+
_activePointer = pointer;
468680
if (!onlyAcceptDragOnThreshold || _hasDragThresholdBeenMet) {
469681
_checkDrag(pointer);
470682
}
@@ -502,6 +714,12 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
502714
if (!_acceptedActivePointers.remove(pointer)) {
503715
resolvePointer(pointer, GestureDisposition.rejected);
504716
}
717+
718+
_moveDeltaBeforeFrame.remove(pointer);
719+
if (_activePointer == pointer) {
720+
_activePointer =
721+
_acceptedActivePointers.isNotEmpty ? _acceptedActivePointers.first : null;
722+
}
505723
}
506724

507725
void _checkDown() {
@@ -687,6 +905,9 @@ class VerticalDragGestureRecognizer extends DragGestureRecognizer {
687905
@override
688906
double _getPrimaryValueFromOffset(Offset value) => value.dy;
689907

908+
@override
909+
_DragDirection? _getPrimaryDragAxis() => _DragDirection.vertical;
910+
690911
@override
691912
String get debugDescription => 'vertical drag';
692913
}
@@ -744,6 +965,9 @@ class HorizontalDragGestureRecognizer extends DragGestureRecognizer {
744965
@override
745966
double _getPrimaryValueFromOffset(Offset value) => value.dx;
746967

968+
@override
969+
_DragDirection? _getPrimaryDragAxis() => _DragDirection.horizontal;
970+
747971
@override
748972
String get debugDescription => 'horizontal drag';
749973
}
@@ -801,3 +1025,8 @@ class PanGestureRecognizer extends DragGestureRecognizer {
8011025
@override
8021026
String get debugDescription => 'pan';
8031027
}
1028+
1029+
enum _DragDirection {
1030+
horizontal,
1031+
vertical,
1032+
}

0 commit comments

Comments
 (0)