Kyle Finlinson b8157ceb60
Use gestureSettings.touchSlop in PrimaryPointerGestureRecognizer (#161549)
<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

Use `gestureSettings.touchSlop` in
`PrimaryPointerGestureRecognizer.handleEvent()` unless a non-default
value (including `null`) is specified.

This behavior will be reflected in subclasses (`TapGestureRecognizer`
and `LongPressGestureRecognizer`) and enable `GestureDetector` tap and
long-press touch slop to be configured out of the box with `MediaQuery`
instead of providing custom gesture recognizers:

```dart
// After this PR you can do this:
child: MediaQuery(
      data: MediaQuery.of(context).copyWith(
        gestureSettings: const DeviceGestureSettings(touchSlop: 64),
      ),
      child: GestureDetector(
        onTap: widget.onTap,
        child: Container(),
      ),


// instead of this:
child: RawGestureDetector(
   gestures: <Type, GestureRecognizerFactory>{
     TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
       // Without https://github.com/flutter/flutter/pull/161541, we'll actually need to make
       // our own custom TapGestureRecognizer class to expose slop parameters too.
       () => TapGestureRecognizer(preAcceptSlopTolerance: 64, postAcceptSlopTolerance: 64, onTap: onTap),
       (TapGestureRecognizer instance) {},
     ),
   },
   child: Container(),
 ),
```

*List which issues are fixed by this PR. You must list at least one
issue. An issue is not required if the PR fixes something trivial like a
typo.*

https://github.com/flutter/flutter/issues/154215

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2025-05-21 22:59:36 +00:00

322 lines
11 KiB
Dart

// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' show VoidCallback;
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
import 'gesture_tester.dart';
// Down/move/up pair 1: normal tap sequence
const PointerDownEvent down = PointerDownEvent(pointer: 5, position: Offset(10, 10));
const PointerMoveEvent move = PointerMoveEvent(pointer: 5, position: Offset(15, 15));
const PointerUpEvent up = PointerUpEvent(pointer: 5, position: Offset(15, 15));
// Down/move/up pair 2: tap sequence with a large move in the middle
const PointerDownEvent down2 = PointerDownEvent(pointer: 6, position: Offset(10, 10));
const PointerMoveEvent move2 = PointerMoveEvent(pointer: 6, position: Offset(100, 200));
const PointerUpEvent up2 = PointerUpEvent(pointer: 6, position: Offset(100, 200));
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('GestureRecognizer smoketest', () {
final TestGestureRecognizer recognizer = TestGestureRecognizer(debugOwner: 0);
expect(recognizer, hasAGoodToStringDeep);
});
test('GestureRecognizer dispatches memory events', () async {
await expectLater(
await memoryEvents(() => TestGestureRecognizer().dispose(), TestGestureRecognizer),
areCreateAndDispose,
);
});
test('OffsetPair', () {
const OffsetPair offset1 = OffsetPair(local: Offset(10, 20), global: Offset(30, 40));
expect(offset1.local, const Offset(10, 20));
expect(offset1.global, const Offset(30, 40));
const OffsetPair offset2 = OffsetPair(local: Offset(50, 60), global: Offset(70, 80));
final OffsetPair sum = offset2 + offset1;
expect(sum.local, const Offset(60, 80));
expect(sum.global, const Offset(100, 120));
final OffsetPair difference = offset2 - offset1;
expect(difference.local, const Offset(40, 40));
expect(difference.global, const Offset(40, 40));
});
group('PrimaryPointerGestureRecognizer', () {
testGesture('cleans up state after winning arena', (GestureTester tester) {
final List<String> resolutions = <String>[];
final IndefiniteGestureRecognizer indefinite = IndefiniteGestureRecognizer();
addTearDown(indefinite.dispose);
final TestPrimaryPointerGestureRecognizer<PointerUpEvent> accepting =
TestPrimaryPointerGestureRecognizer<PointerUpEvent>(
GestureDisposition.accepted,
onAcceptGesture: () => resolutions.add('accepted'),
onRejectGesture: () => resolutions.add('rejected'),
);
addTearDown(accepting.dispose);
expect(accepting.state, GestureRecognizerState.ready);
expect(accepting.primaryPointer, isNull);
expect(accepting.initialPosition, isNull);
expect(resolutions, <String>[]);
indefinite.addPointer(down);
accepting.addPointer(down);
expect(accepting.state, GestureRecognizerState.possible);
expect(accepting.primaryPointer, 5);
expect(accepting.initialPosition!.global, down.position);
expect(accepting.initialPosition!.local, down.localPosition);
expect(resolutions, <String>[]);
tester.closeArena(5);
tester.async.flushMicrotasks();
tester.route(down);
tester.route(up);
expect(accepting.state, GestureRecognizerState.ready);
expect(accepting.primaryPointer, 5);
expect(accepting.initialPosition, isNull);
expect(resolutions, <String>['accepted']);
});
testGesture('cleans up state after losing arena', (GestureTester tester) {
final List<String> resolutions = <String>[];
final IndefiniteGestureRecognizer indefinite = IndefiniteGestureRecognizer();
addTearDown(indefinite.dispose);
final TestPrimaryPointerGestureRecognizer<PointerMoveEvent> rejecting =
TestPrimaryPointerGestureRecognizer<PointerMoveEvent>(
GestureDisposition.rejected,
onAcceptGesture: () => resolutions.add('accepted'),
onRejectGesture: () => resolutions.add('rejected'),
);
addTearDown(rejecting.dispose);
expect(rejecting.state, GestureRecognizerState.ready);
expect(rejecting.primaryPointer, isNull);
expect(rejecting.initialPosition, isNull);
expect(resolutions, <String>[]);
indefinite.addPointer(down);
rejecting.addPointer(down);
expect(rejecting.state, GestureRecognizerState.possible);
expect(rejecting.primaryPointer, 5);
expect(rejecting.initialPosition!.global, down.position);
expect(rejecting.initialPosition!.local, down.localPosition);
expect(resolutions, <String>[]);
tester.closeArena(5);
tester.async.flushMicrotasks();
tester.route(down);
tester.route(move);
expect(rejecting.state, GestureRecognizerState.defunct);
expect(rejecting.primaryPointer, 5);
expect(rejecting.initialPosition!.global, down.position);
expect(rejecting.initialPosition!.local, down.localPosition);
expect(resolutions, <String>['rejected']);
tester.route(up);
expect(rejecting.state, GestureRecognizerState.ready);
expect(rejecting.primaryPointer, 5);
expect(rejecting.initialPosition, isNull);
expect(resolutions, <String>['rejected']);
});
testGesture('works properly when recycled', (GestureTester tester) {
final List<String> resolutions = <String>[];
final IndefiniteGestureRecognizer indefinite = IndefiniteGestureRecognizer();
addTearDown(indefinite.dispose);
final TestPrimaryPointerGestureRecognizer<PointerUpEvent> accepting =
TestPrimaryPointerGestureRecognizer<PointerUpEvent>(
GestureDisposition.accepted,
preAcceptSlopTolerance: 15,
postAcceptSlopTolerance: 1000,
onAcceptGesture: () => resolutions.add('accepted'),
onRejectGesture: () => resolutions.add('rejected'),
);
addTearDown(accepting.dispose);
// Send one complete pointer sequence
indefinite.addPointer(down);
accepting.addPointer(down);
tester.closeArena(5);
tester.async.flushMicrotasks();
tester.route(down);
tester.route(up);
expect(resolutions, <String>['accepted']);
resolutions.clear();
// Send a follow-on sequence that breaks preAcceptSlopTolerance
indefinite.addPointer(down2);
accepting.addPointer(down2);
tester.closeArena(6);
tester.async.flushMicrotasks();
tester.route(down2);
tester.route(move2);
expect(resolutions, <String>['rejected']);
tester.route(up2);
expect(resolutions, <String>['rejected']);
});
testGesture('uses expected pre-accept slop tolerance', (GestureTester tester) {
final List<String> resolutions = <String>[];
final IndefiniteGestureRecognizer indefinite = IndefiniteGestureRecognizer();
addTearDown(indefinite.dispose);
final TestPrimaryPointerGestureRecognizer<PointerUpEvent> defaultSlop =
TestPrimaryPointerGestureRecognizer<PointerUpEvent>(
GestureDisposition.accepted,
postAcceptSlopTolerance: null,
onAcceptGesture: () => resolutions.add('accepted'),
onRejectGesture: () => resolutions.add('rejected'),
);
addTearDown(defaultSlop.dispose);
final TestPrimaryPointerGestureRecognizer<PointerUpEvent> setSlop =
TestPrimaryPointerGestureRecognizer<PointerUpEvent>(
GestureDisposition.accepted,
preAcceptSlopTolerance: 5,
postAcceptSlopTolerance: null,
onAcceptGesture: () => resolutions.add('accepted'),
onRejectGesture: () => resolutions.add('rejected'),
);
addTearDown(setSlop.dispose);
final TestPrimaryPointerGestureRecognizer<PointerUpEvent> nullSlop =
TestPrimaryPointerGestureRecognizer<PointerUpEvent>(
GestureDisposition.accepted,
preAcceptSlopTolerance: null,
postAcceptSlopTolerance: null,
onAcceptGesture: () => resolutions.add('accepted'),
onRejectGesture: () => resolutions.add('rejected'),
);
addTearDown(nullSlop.dispose);
// Test getters
expect(defaultSlop.preAcceptSlopTolerance, equals(kTouchSlop));
expect(setSlop.preAcceptSlopTolerance, equals(5.0));
expect(nullSlop.preAcceptSlopTolerance, isNull);
indefinite.addPointer(down);
defaultSlop.addPointer(down);
tester.closeArena(5);
tester.async.flushMicrotasks();
tester.route(down);
tester.route(move);
tester.route(up);
expect(resolutions, <String>['accepted']);
resolutions.clear();
defaultSlop.gestureSettings = const DeviceGestureSettings(touchSlop: 5);
indefinite.addPointer(down);
defaultSlop.addPointer(down);
tester.closeArena(5);
tester.async.flushMicrotasks();
tester.route(down);
tester.route(move);
tester.route(up);
expect(resolutions, <String>['rejected']);
resolutions.clear();
indefinite.addPointer(down);
setSlop.addPointer(down);
tester.closeArena(5);
tester.async.flushMicrotasks();
tester.route(down);
tester.route(move);
tester.route(up);
expect(resolutions, <String>['rejected']);
resolutions.clear();
indefinite.addPointer(down2);
nullSlop.addPointer(down2);
tester.closeArena(6);
tester.async.flushMicrotasks();
tester.route(down2);
tester.route(move2);
tester.route(up2);
expect(resolutions, <String>['accepted']);
});
});
}
class TestGestureRecognizer extends GestureRecognizer {
TestGestureRecognizer({super.debugOwner});
@override
String get debugDescription => 'debugDescription content';
@override
void addPointer(PointerDownEvent event) {}
@override
void acceptGesture(int pointer) {}
@override
void rejectGesture(int pointer) {}
}
/// Gesture recognizer that adds itself to the gesture arena but never
/// resolves itself.
class IndefiniteGestureRecognizer extends GestureRecognizer {
@override
void addAllowedPointer(PointerDownEvent event) {
GestureBinding.instance.gestureArena.add(event.pointer, this);
}
@override
void acceptGesture(int pointer) {}
@override
void rejectGesture(int pointer) {}
@override
String get debugDescription => 'Unresolving';
}
/// Gesture recognizer that resolves with [resolution] when it handles an event
/// on the primary pointer of type [T]
class TestPrimaryPointerGestureRecognizer<T extends PointerEvent>
extends PrimaryPointerGestureRecognizer {
TestPrimaryPointerGestureRecognizer(
this.resolution, {
this.onAcceptGesture,
this.onRejectGesture,
super.preAcceptSlopTolerance,
super.postAcceptSlopTolerance,
});
final GestureDisposition resolution;
final VoidCallback? onAcceptGesture;
final VoidCallback? onRejectGesture;
@override
void acceptGesture(int pointer) {
super.acceptGesture(pointer);
onAcceptGesture?.call();
}
@override
void rejectGesture(int pointer) {
super.rejectGesture(pointer);
onRejectGesture?.call();
}
@override
void handlePrimaryPointer(PointerEvent event) {
if (event is T) {
resolve(resolution);
}
}
@override
String get debugDescription => 'TestPrimaryPointer';
}