import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; enum CustomTouchGestureState { none, oneFingerPan, twoFingerScale, threeFingerVerticalDrag } class CustomTouchGestureRecognizer extends ScaleGestureRecognizer { CustomTouchGestureRecognizer({ Object? debugOwner, Set? supportedDevices, }) : super( debugOwner: debugOwner, supportedDevices: supportedDevices, ) { _init(); } // oneFingerPan GestureDragStartCallback? onOneFingerPanStart; GestureDragUpdateCallback? onOneFingerPanUpdate; GestureDragEndCallback? onOneFingerPanEnd; // twoFingerScale : scale + pan event GestureScaleStartCallback? onTwoFingerScaleStart; GestureScaleUpdateCallback? onTwoFingerScaleUpdate; GestureScaleEndCallback? onTwoFingerScaleEnd; // threeFingerVerticalDrag GestureDragStartCallback? onThreeFingerVerticalDragStart; GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate; GestureDragEndCallback? onThreeFingerVerticalDragEnd; var _currentState = CustomTouchGestureState.none; Timer? _startEventDebounceTimer; void _init() { debugPrint("CustomTouchGestureRecognizer init"); onStart = (d) { _startEventDebounceTimer?.cancel(); if (d.pointerCount == 1) { _currentState = CustomTouchGestureState.oneFingerPan; if (onOneFingerPanStart != null) { onOneFingerPanStart!(DragStartDetails( localPosition: d.localFocalPoint, globalPosition: d.focalPoint)); } debugPrint("start oneFingerPan"); } else if (d.pointerCount == 2) { if (_currentState == CustomTouchGestureState.threeFingerVerticalDrag) { // 3 -> 2 debounce _startEventDebounceTimer = Timer(Duration(milliseconds: 200), () { _currentState = CustomTouchGestureState.twoFingerScale; if (onTwoFingerScaleStart != null) { onTwoFingerScaleStart!(ScaleStartDetails( localFocalPoint: d.localFocalPoint, focalPoint: d.focalPoint)); } debugPrint("debounce start twoFingerScale success"); }); } _currentState = CustomTouchGestureState.twoFingerScale; // startWatchTimer(); if (onTwoFingerScaleStart != null) { onTwoFingerScaleStart!(ScaleStartDetails( localFocalPoint: d.localFocalPoint, focalPoint: d.focalPoint)); } debugPrint("start twoFingerScale"); } else if (d.pointerCount == 3) { _currentState = CustomTouchGestureState.threeFingerVerticalDrag; if (onThreeFingerVerticalDragStart != null) { onThreeFingerVerticalDragStart!( DragStartDetails(globalPosition: d.localFocalPoint)); } debugPrint("start threeFingerScale"); // _reset(); } }; onUpdate = (d) { if (_currentState != CustomTouchGestureState.none) { switch (_currentState) { case CustomTouchGestureState.oneFingerPan: if (onOneFingerPanUpdate != null) { onOneFingerPanUpdate!(_getDragUpdateDetails(d)); } break; case CustomTouchGestureState.twoFingerScale: if (onTwoFingerScaleUpdate != null) { onTwoFingerScaleUpdate!(d); } break; case CustomTouchGestureState.threeFingerVerticalDrag: if (onThreeFingerVerticalDragUpdate != null) { onThreeFingerVerticalDragUpdate!(_getDragUpdateDetails(d)); } break; default: break; } return; } }; onEnd = (d) { debugPrint("ScaleGestureRecognizer onEnd"); // end switch (_currentState) { case CustomTouchGestureState.oneFingerPan: debugPrint("TwoFingerState.pan onEnd"); if (onOneFingerPanEnd != null) { onOneFingerPanEnd!(_getDragEndDetails(d)); } break; case CustomTouchGestureState.twoFingerScale: debugPrint("TwoFingerState.scale onEnd"); if (onTwoFingerScaleEnd != null) { onTwoFingerScaleEnd!(d); } break; case CustomTouchGestureState.threeFingerVerticalDrag: debugPrint("ThreeFingerState.vertical onEnd"); if (onThreeFingerVerticalDragEnd != null) { onThreeFingerVerticalDragEnd!(_getDragEndDetails(d)); } break; default: break; } _currentState = CustomTouchGestureState.none; }; } DragUpdateDetails _getDragUpdateDetails(ScaleUpdateDetails d) => DragUpdateDetails( globalPosition: d.focalPoint, localPosition: d.localFocalPoint, delta: d.focalPointDelta); DragEndDetails _getDragEndDetails(ScaleEndDetails d) => DragEndDetails(velocity: d.velocity); } class HoldTapMoveGestureRecognizer extends GestureRecognizer { HoldTapMoveGestureRecognizer({ Object? debugOwner, Set? supportedDevices, }) : super( debugOwner: debugOwner, supportedDevices: supportedDevices, ); GestureDragStartCallback? onHoldDragStart; GestureDragUpdateCallback? onHoldDragUpdate; GestureDragDownCallback? onHoldDragDown; GestureDragCancelCallback? onHoldDragCancel; GestureDragEndCallback? onHoldDragEnd; bool _isStart = false; Timer? _firstTapUpTimer; Timer? _secondTapDownTimer; _TapTracker? _firstTap; _TapTracker? _secondTap; final Map _trackers = {}; @override bool isPointerAllowed(PointerDownEvent event) { if (_firstTap == null) { switch (event.buttons) { case kPrimaryButton: if (onHoldDragStart == null && onHoldDragUpdate == null && onHoldDragCancel == null && onHoldDragEnd == null) { return false; } break; default: return false; } } return super.isPointerAllowed(event); } @override void addAllowedPointer(PointerDownEvent event) { if (_firstTap != null) { if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) { // Ignore out-of-bounds second taps. return; } else if (!_firstTap!.hasElapsedMinTime() || !_firstTap!.hasSameButton(event)) { // Restart when the second tap is too close to the first (touch screens // often detect touches intermittently), or when buttons mismatch. _reset(); return _trackTap(event); } else if (onHoldDragDown != null) { invokeCallback( 'onHoldDragDown', () => onHoldDragDown!(DragDownDetails( globalPosition: event.position, localPosition: event.localPosition))); } } _trackTap(event); } void _trackTap(PointerDownEvent event) { _stopFirstTapUpTimer(); _stopSecondTapDownTimer(); final _TapTracker tracker = _TapTracker( event: event, entry: GestureBinding.instance.gestureArena.add(event.pointer, this), doubleTapMinTime: kDoubleTapMinTime, gestureSettings: gestureSettings, ); _trackers[event.pointer] = tracker; tracker.startTrackingPointer(_handleEvent, event.transform); } void _handleEvent(PointerEvent event) { final _TapTracker tracker = _trackers[event.pointer]!; if (event is PointerUpEvent) { if (_firstTap == null && _secondTap == null) { _registerFirstTap(tracker); } else if (_secondTap != null) { if (event.pointer == _secondTap!.pointer) { if (onHoldDragEnd != null) onHoldDragEnd!(DragEndDetails()); } } else { _reject(tracker); } } else if (event is PointerDownEvent) { if (_firstTap != null && _secondTap == null) { _registerSecondTap(tracker); } } else if (event is PointerMoveEvent) { if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) { if (_firstTap != null && _firstTap!.pointer == event.pointer) { // first tap move _reject(tracker); } else if (_secondTap != null && _secondTap!.pointer == event.pointer) { // debugPrint("_secondTap move"); // second tap move if (!_isStart) { _resolve(); } if (onHoldDragUpdate != null) onHoldDragUpdate!(DragUpdateDetails( globalPosition: event.position, localPosition: event.localPosition, delta: event.delta)); } } } else if (event is PointerCancelEvent) { _reject(tracker); } } @override void acceptGesture(int pointer) {} @override void rejectGesture(int pointer) { _TapTracker? tracker = _trackers[pointer]; // If tracker isn't in the list, check if this is the first tap tracker if (tracker == null && _firstTap != null && _firstTap!.pointer == pointer) { tracker = _firstTap; } // If tracker is still null, we rejected ourselves already if (tracker != null) { _reject(tracker); } } void _resolve() { _stopSecondTapDownTimer(); _firstTap?.entry.resolve(GestureDisposition.accepted); _secondTap?.entry.resolve(GestureDisposition.accepted); _isStart = true; // TODO start details if (onHoldDragStart != null) onHoldDragStart!(DragStartDetails()); } void _reject(_TapTracker tracker) { try { _checkCancel(); _isStart = false; _trackers.remove(tracker.pointer); tracker.entry.resolve(GestureDisposition.rejected); _freezeTracker(tracker); _reset(); } catch (e) { debugPrint("Failed to _reject:$e"); } } @override void dispose() { _reset(); super.dispose(); } void _reset() { _isStart = false; // debugPrint("reset"); _stopFirstTapUpTimer(); _stopSecondTapDownTimer(); if (_firstTap != null) { if (_trackers.isNotEmpty) { _checkCancel(); } // Note, order is important below in order for the resolve -> reject logic // to work properly. final _TapTracker tracker = _firstTap!; _firstTap = null; _reject(tracker); GestureBinding.instance.gestureArena.release(tracker.pointer); if (_secondTap != null) { final _TapTracker tracker = _secondTap!; _secondTap = null; _reject(tracker); GestureBinding.instance.gestureArena.release(tracker.pointer); } } _firstTap = null; _secondTap = null; _clearTrackers(); } void _registerFirstTap(_TapTracker tracker) { _startFirstTapUpTimer(); GestureBinding.instance.gestureArena.hold(tracker.pointer); // Note, order is important below in order for the clear -> reject logic to // work properly. _freezeTracker(tracker); _trackers.remove(tracker.pointer); _firstTap = tracker; } void _registerSecondTap(_TapTracker tracker) { if (_firstTap != null) { _stopFirstTapUpTimer(); _freezeTracker(_firstTap!); _firstTap = null; } _startSecondTapDownTimer(); GestureBinding.instance.gestureArena.hold(tracker.pointer); _secondTap = tracker; // TODO } void _clearTrackers() { _trackers.values.toList().forEach(_reject); assert(_trackers.isEmpty); } void _freezeTracker(_TapTracker tracker) { tracker.stopTrackingPointer(_handleEvent); } void _startFirstTapUpTimer() { _firstTapUpTimer ??= Timer(kDoubleTapTimeout, _reset); } void _startSecondTapDownTimer() { _secondTapDownTimer ??= Timer(kDoubleTapTimeout, _resolve); } void _stopFirstTapUpTimer() { if (_firstTapUpTimer != null) { _firstTapUpTimer!.cancel(); _firstTapUpTimer = null; } } void _stopSecondTapDownTimer() { if (_secondTapDownTimer != null) { _secondTapDownTimer!.cancel(); _secondTapDownTimer = null; } } void _checkCancel() { if (onHoldDragCancel != null) { invokeCallback('onHoldDragCancel', onHoldDragCancel!); } } @override String get debugDescription => 'double tap'; } class DoubleFinerTapGestureRecognizer extends GestureRecognizer { DoubleFinerTapGestureRecognizer({ Object? debugOwner, Set? supportedDevices, }) : super( debugOwner: debugOwner, supportedDevices: supportedDevices, ); GestureTapDownCallback? onDoubleFinerTapDown; GestureTapDownCallback? onDoubleFinerTap; GestureTapCancelCallback? onDoubleFinerTapCancel; Timer? _firstTapTimer; _TapTracker? _firstTap; var _isStart = false; final Set _upTap = {}; final Map _trackers = {}; @override bool isPointerAllowed(PointerDownEvent event) { if (_firstTap == null) { switch (event.buttons) { case kPrimaryButton: if (onDoubleFinerTapDown == null && onDoubleFinerTap == null && onDoubleFinerTapCancel == null) { return false; } break; default: return false; } } return super.isPointerAllowed(event); } @override void addAllowedPointer(PointerDownEvent event) { debugPrint("addAllowedPointer"); if (_isStart) { // second if (onDoubleFinerTapDown != null) { final TapDownDetails details = TapDownDetails( globalPosition: event.position, localPosition: event.localPosition, kind: getKindForPointer(event.pointer), ); invokeCallback( 'onDoubleFinerTapDown', () => onDoubleFinerTapDown!(details)); } } else { // first tap _isStart = true; _startFirstTapDownTimer(); } _trackTap(event); } void _trackTap(PointerDownEvent event) { final _TapTracker tracker = _TapTracker( event: event, entry: GestureBinding.instance.gestureArena.add(event.pointer, this), doubleTapMinTime: kDoubleTapMinTime, gestureSettings: gestureSettings, ); _trackers[event.pointer] = tracker; // debugPrint("_trackers:$_trackers"); tracker.startTrackingPointer(_handleEvent, event.transform); _registerTap(tracker); } void _handleEvent(PointerEvent event) { final _TapTracker tracker = _trackers[event.pointer]!; if (event is PointerUpEvent) { debugPrint("PointerUpEvent"); _upTap.add(tracker.pointer); } else if (event is PointerMoveEvent) { if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) _reject(tracker); } else if (event is PointerCancelEvent) { _reject(tracker); } } @override void acceptGesture(int pointer) {} @override void rejectGesture(int pointer) { _TapTracker? tracker = _trackers[pointer]; // If tracker isn't in the list, check if this is the first tap tracker if (tracker == null && _firstTap != null && _firstTap!.pointer == pointer) { tracker = _firstTap; } // If tracker is still null, we rejected ourselves already if (tracker != null) { _reject(tracker); } } void _reject(_TapTracker tracker) { _trackers.remove(tracker.pointer); tracker.entry.resolve(GestureDisposition.rejected); _freezeTracker(tracker); if (_firstTap != null) { if (tracker == _firstTap) { _reset(); } else { _checkCancel(); if (_trackers.isEmpty) { _reset(); } } } } @override void dispose() { _reset(); super.dispose(); } void _reset() { _stopFirstTapUpTimer(); _firstTap = null; _clearTrackers(); } void _registerTap(_TapTracker tracker) { GestureBinding.instance.gestureArena.hold(tracker.pointer); // Note, order is important below in order for the clear -> reject logic to // work properly. } void _clearTrackers() { _trackers.values.toList().forEach(_reject); assert(_trackers.isEmpty); } void _freezeTracker(_TapTracker tracker) { tracker.stopTrackingPointer(_handleEvent); } void _startFirstTapDownTimer() { _firstTapTimer ??= Timer(kDoubleTapTimeout, _timeoutCheck); } void _stopFirstTapUpTimer() { if (_firstTapTimer != null) { _firstTapTimer!.cancel(); _firstTapTimer = null; } } void _timeoutCheck() { _isStart = false; if (_upTap.length == 2) { _resolve(); } else { _reset(); } _upTap.clear(); } void _resolve() { // TODO tap down details if (onDoubleFinerTap != null) onDoubleFinerTap!(TapDownDetails()); _trackers.forEach((key, value) { value.entry.resolve(GestureDisposition.accepted); }); _reset(); } void _checkCancel() { if (onDoubleFinerTapCancel != null) { invokeCallback('onHoldDragCancel', onDoubleFinerTapCancel!); } } @override String get debugDescription => 'double tap'; } /// TapTracker helps track individual tap sequences as part of a /// larger gesture. class _TapTracker { _TapTracker({ required PointerDownEvent event, required this.entry, required Duration doubleTapMinTime, required this.gestureSettings, }) : assert(doubleTapMinTime != null), assert(event != null), assert(event.buttons != null), pointer = event.pointer, _initialGlobalPosition = event.position, initialButtons = event.buttons, _doubleTapMinTimeCountdown = _CountdownZoned(duration: doubleTapMinTime); final DeviceGestureSettings? gestureSettings; final int pointer; final GestureArenaEntry entry; final Offset _initialGlobalPosition; final int initialButtons; final _CountdownZoned _doubleTapMinTimeCountdown; bool _isTrackingPointer = false; void startTrackingPointer(PointerRoute route, Matrix4? transform) { if (!_isTrackingPointer) { _isTrackingPointer = true; GestureBinding.instance.pointerRouter.addRoute(pointer, route, transform); } } void stopTrackingPointer(PointerRoute route) { if (_isTrackingPointer) { _isTrackingPointer = false; GestureBinding.instance.pointerRouter.removeRoute(pointer, route); } } bool isWithinGlobalTolerance(PointerEvent event, double tolerance) { final Offset offset = event.position - _initialGlobalPosition; return offset.distance <= tolerance; } bool hasElapsedMinTime() { return _doubleTapMinTimeCountdown.timeout; } bool hasSameButton(PointerDownEvent event) { return event.buttons == initialButtons; } } /// CountdownZoned tracks whether the specified duration has elapsed since /// creation, honoring [Zone]. class _CountdownZoned { _CountdownZoned({required Duration duration}) : assert(duration != null) { Timer(duration, _onTimeout); } bool _timeout = false; bool get timeout => _timeout; void _onTimeout() { _timeout = true; } } RawGestureDetector getMixinGestureDetector({ Widget? child, GestureTapUpCallback? onTapUp, GestureTapDownCallback? onDoubleTapDown, GestureDoubleTapCallback? onDoubleTap, GestureLongPressDownCallback? onLongPressDown, GestureLongPressCallback? onLongPress, GestureDragStartCallback? onHoldDragStart, GestureDragUpdateCallback? onHoldDragUpdate, GestureDragCancelCallback? onHoldDragCancel, GestureDragEndCallback? onHoldDragEnd, GestureTapDownCallback? onDoubleFinerTap, GestureDragStartCallback? onOneFingerPanStart, GestureDragUpdateCallback? onOneFingerPanUpdate, GestureDragEndCallback? onOneFingerPanEnd, GestureScaleUpdateCallback? onTwoFingerScaleUpdate, GestureScaleEndCallback? onTwoFingerScaleEnd, GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate, }) { return RawGestureDetector( child: child, gestures: { // Official TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), (instance) { instance.onTapUp = onTapUp; }), DoubleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => DoubleTapGestureRecognizer(), (instance) { instance ..onDoubleTapDown = onDoubleTapDown ..onDoubleTap = onDoubleTap; }), LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => LongPressGestureRecognizer(), (instance) { instance ..onLongPressDown = onLongPressDown ..onLongPress = onLongPress; }), // Customized HoldTapMoveGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => HoldTapMoveGestureRecognizer(), (instance) => { instance ..onHoldDragStart = onHoldDragStart ..onHoldDragUpdate = onHoldDragUpdate ..onHoldDragCancel = onHoldDragCancel ..onHoldDragEnd = onHoldDragEnd }), DoubleFinerTapGestureRecognizer: GestureRecognizerFactoryWithHandlers< DoubleFinerTapGestureRecognizer>( () => DoubleFinerTapGestureRecognizer(), (instance) { instance.onDoubleFinerTap = onDoubleFinerTap; }), CustomTouchGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => CustomTouchGestureRecognizer(), (instance) { instance ..onOneFingerPanStart = onOneFingerPanStart ..onOneFingerPanUpdate = onOneFingerPanUpdate ..onOneFingerPanEnd = onOneFingerPanEnd ..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate ..onTwoFingerScaleEnd = onTwoFingerScaleEnd ..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate; }), }); }