diff --git a/lib/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart b/lib/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart index c04b8f962..e453f8bcf 100644 --- a/lib/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart +++ b/lib/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart @@ -1,26 +1,29 @@ import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:flutter/gestures.dart'; -class CustomHorizontalDragGestureRecognizer - extends HorizontalDragGestureRecognizer { - CustomHorizontalDragGestureRecognizer({ - super.debugOwner, - super.supportedDevices, - super.allowedButtonsFilter, - }); - +mixin InitialPositionMixin on GestureRecognizer { Offset? _initialPosition; Offset? get initialPosition => _initialPosition; - @override - DeviceGestureSettings get gestureSettings => _gestureSettings; - final _gestureSettings = DeviceGestureSettings(touchSlop: touchSlopH); - @override void addAllowedPointer(PointerDownEvent event) { super.addAllowedPointer(event); _initialPosition = event.position; } +} + +class CustomHorizontalDragGestureRecognizer + extends HorizontalDragGestureRecognizer + with InitialPositionMixin { + CustomHorizontalDragGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + }); + + @override + DeviceGestureSettings get gestureSettings => _gestureSettings; + final _gestureSettings = DeviceGestureSettings(touchSlop: touchSlopH); @override bool hasSufficientGlobalDistanceToAccept( @@ -41,7 +44,7 @@ double touchSlopH = Pref.touchSlopH; bool _computeHitSlop( double globalDistanceMoved, - DeviceGestureSettings? settings, + DeviceGestureSettings settings, PointerDeviceKind kind, Offset? initialPosition, Offset lastPosition, @@ -53,10 +56,10 @@ bool _computeHitSlop( case PointerDeviceKind.invertedStylus: case PointerDeviceKind.unknown: case PointerDeviceKind.touch: - return globalDistanceMoved > touchSlopH && + return globalDistanceMoved > settings.touchSlop! && _calc(initialPosition!, lastPosition); case PointerDeviceKind.trackpad: - return globalDistanceMoved > (settings?.touchSlop ?? kTouchSlop); + return globalDistanceMoved > settings.touchSlop!; } } diff --git a/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart b/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart index 19c1b8c09..f4170ab94 100644 --- a/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart +++ b/lib/common/widgets/gesture/image_horizontal_drag_gesture_recognizer.dart @@ -1,22 +1,22 @@ import 'package:PiliPlus/common/widgets/gesture/horizontal_drag_gesture_recognizer.dart'; +import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:flutter/gestures.dart'; mixin ImageGestureRecognizerMixin on GestureRecognizer { int? _pointer; @override - void addPointer(PointerDownEvent event) { + void addPointer(PointerDownEvent event, {bool isPointerAllowed = true}) { if (_pointer == event.pointer) { return; } _pointer = event.pointer; - super.addPointer(event); + if (isPointerAllowed) { + super.addPointer(event); + } } } -typedef IsBoundaryAllowed = - bool Function(Offset? initialPosition, OffsetPair lastPosition); - class ImageHorizontalDragGestureRecognizer extends CustomHorizontalDragGestureRecognizer with ImageGestureRecognizerMixin { @@ -26,23 +26,62 @@ class ImageHorizontalDragGestureRecognizer super.allowedButtonsFilter, }); - IsBoundaryAllowed? isBoundaryAllowed; + static final double _touchSlop = PlatformUtils.isDesktop + ? kPrecisePointerHitSlop + : 3.0; @override - bool hasSufficientGlobalDistanceToAccept( - PointerDeviceKind pointerDeviceKind, - double? deviceTouchSlop, - ) { - return super.hasSufficientGlobalDistanceToAccept( - pointerDeviceKind, - deviceTouchSlop, - ) && - (isBoundaryAllowed?.call(initialPosition, lastPosition) ?? true); + DeviceGestureSettings get gestureSettings => _gestureSettings; + final _gestureSettings = DeviceGestureSettings(touchSlop: _touchSlop); + + bool isAtLeftEdge = false; + bool isAtRightEdge = false; + + void setAtBothEdges() { + isAtLeftEdge = isAtRightEdge = true; + } + + bool _isEdgeAllowed(double dx) { + if ((initialPosition!.dx - dx).abs() < _touchSlop) return true; + if (isAtLeftEdge) { + if (isAtRightEdge) { + return _hasAcceptedOrChecked = true; + } + _hasAcceptedOrChecked = true; + return initialPosition!.dx < dx; + } else if (isAtRightEdge) { + _hasAcceptedOrChecked = true; + return initialPosition!.dx > dx; + } + return true; } @override - void dispose() { - isBoundaryAllowed = null; - super.dispose(); + void handleEvent(PointerEvent event) { + if (!_hasAcceptedOrChecked && + event is PointerMoveEvent && + _pointer == event.pointer) { + if (!_isEdgeAllowed(event.position.dx)) { + rejectGesture(event.pointer); + return; + } + } + super.handleEvent(event); + } + + bool _hasAcceptedOrChecked = false; + + @override + void acceptGesture(int pointer) { + _hasAcceptedOrChecked = true; + super.acceptGesture(pointer); + } + + @override + void stopTrackingPointer(int pointer) { + _hasAcceptedOrChecked = false; + isAtLeftEdge = false; + isAtRightEdge = false; + super.stopTrackingPointer(pointer); } } diff --git a/lib/common/widgets/image_viewer/viewer.dart b/lib/common/widgets/image_viewer/viewer.dart index 4b97274e8..1611995e5 100644 --- a/lib/common/widgets/image_viewer/viewer.dart +++ b/lib/common/widgets/image_viewer/viewer.dart @@ -26,6 +26,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart' show FrictionSimulation; +import 'package:flutter/scheduler.dart' show SchedulerBinding; import 'package:flutter/services.dart' show HardwareKeyboard; /// @@ -69,7 +70,6 @@ class Viewer extends StatefulWidget { } class _ViewerState extends State with SingleTickerProviderStateMixin { - double get _interactionEndFrictionCoefficient => 0.0001 * _scale; // 0.0000135 static const double _scaleFactor = kDefaultMouseScrollToScaleFactor; _GestureType? _gestureType; @@ -171,6 +171,7 @@ class _ViewerState extends State with SingleTickerProviderStateMixin { @override void dispose() { + _stopFling(); _animationController ..removeListener(_listener) ..dispose(); @@ -265,6 +266,8 @@ class _ViewerState extends State with SingleTickerProviderStateMixin { } void _onScaleStart(ScaleStartDetails details) { + _stopFling(); + if (_animationController.isAnimating) { _animationController.stop(); } @@ -329,40 +332,106 @@ class _ViewerState extends State with SingleTickerProviderStateMixin { } } + /// ref https://github.com/ahnaineh/custom_interactive_viewer + int? _flingFrameCallbackId; + Simulation? _flingSimulation; + Duration? _flingStartTime; + double _lastFlingElapsedSeconds = 0.0; + Offset _flingDirection = Offset.zero; + + /// Calculate appropriate friction based on velocity magnitude + double _calculateDynamicFriction(double velocityMagnitude) { + // Use higher friction for faster flicks + // These values can be tuned for the feel you want + if (velocityMagnitude > 5000) { + return 0.03; // Higher friction for very fast flicks + } else if (velocityMagnitude > 3000) { + return 0.02; // Medium friction for moderate flicks + } else { + return 0.01; // Lower friction for gentle movements + } + } + + void _startFling(Velocity velocity) { + _stopFling(); + + final double velocityMagnitude = velocity.pixelsPerSecond.distance; + final double frictionCoefficient = _calculateDynamicFriction( + velocityMagnitude, + ); + + _flingSimulation = FrictionSimulation( + frictionCoefficient, + 0.0, + velocityMagnitude, + ); + + _flingDirection = velocityMagnitude > 0 + ? velocity.pixelsPerSecond / velocityMagnitude + : Offset.zero; + + _flingStartTime = null; + _lastFlingElapsedSeconds = 0.0; + _scheduleFlingFrame(); + } + + void _scheduleFlingFrame() { + _flingFrameCallbackId = SchedulerBinding.instance.scheduleFrameCallback( + _handleFlingFrame, + ); + } + + void _handleFlingFrame(Duration timeStamp) { + if (_flingSimulation == null) return; + + _flingStartTime ??= timeStamp; + final double elapsedSeconds = + (timeStamp - _flingStartTime!).inMicroseconds / 1e6; + + final double distance = _flingSimulation!.x(elapsedSeconds); + final double prevDistance = _flingSimulation!.x(_lastFlingElapsedSeconds); + final double delta = distance - prevDistance; + _lastFlingElapsedSeconds = elapsedSeconds; + + if ((prevDistance != 0.0 && delta.abs() < 0.1) || + _flingSimulation!.isDone(elapsedSeconds)) { + _stopFling(); + return; + } + + final Offset movement = _flingDirection * delta; + _position = _clampPosition(_position + movement, _scale); + setState(() {}); + + if (_flingSimulation!.isDone(elapsedSeconds)) { + _stopFling(); + } else { + _scheduleFlingFrame(); + } + } + + void _stopFling() { + if (_flingFrameCallbackId != null) { + SchedulerBinding.instance.cancelFrameCallbackWithId( + _flingFrameCallbackId!, + ); + _flingFrameCallbackId = null; + } + _flingStartTime = null; + _lastFlingElapsedSeconds = 0.0; + _flingSimulation = null; + } + /// ref [InteractiveViewer] void _onScaleEnd(ScaleEndDetails details) { switch (_gestureType) { case _GestureType.pan: - if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) { - return; + final double velocityMagnitude = + details.velocity.pixelsPerSecond.distance; + if (velocityMagnitude >= 200.0) { + _startFling(details.velocity); } - final drag = _interactionEndFrictionCoefficient; - final FrictionSimulation frictionSimulationX = FrictionSimulation( - drag, - _position.dx, - details.velocity.pixelsPerSecond.dx, - ); - final FrictionSimulation frictionSimulationY = FrictionSimulation( - drag, - _position.dy, - details.velocity.pixelsPerSecond.dy, - ); - final double tFinal = _getFinalTime( - details.velocity.pixelsPerSecond.distance, - drag, - ); - final position = _clampPosition( - Offset(frictionSimulationX.finalX, frictionSimulationY.finalX), - _scale, - ); - _scaleFrom = _scaleTo = _scale; - _positionFrom = _position; - _positionTo = position; - - _animationController - ..duration = Duration(milliseconds: (tFinal * 1000).round()) - ..forward(from: 0); case _GestureType.scale: // if (details.scaleVelocity.abs() < 0.1) { // return; @@ -417,9 +486,10 @@ class _ViewerState extends State with SingleTickerProviderStateMixin { _doubleTapGestureRecognizer ..onDoubleTapDown = _onDoubleTapDown ..onDoubleTap = _onDoubleTap; - _horizontalDragGestureRecognizer - ..isBoundaryAllowed = _isBoundaryAllowed - ..addPointer(event); + _horizontalDragGestureRecognizer.addPointer( + event, + isPointerAllowed: _isAtEdge(event.localPosition), + ); _scaleGestureRecognizer.addPointer(event); } @@ -427,25 +497,28 @@ class _ViewerState extends State with SingleTickerProviderStateMixin { _scaleGestureRecognizer.addPointerPanZoom(event); } - bool _isBoundaryAllowed(Offset? initialPosition, OffsetPair lastPosition) { - if (initialPosition == null) { - return true; - } + bool _isAtEdge(Offset position) { if (_scale <= widget.minScale) { + _horizontalDragGestureRecognizer.setAtBothEdges(); return true; } final containerWidth = widget.containerSize.width; final imageWidth = _imageSize.width * _scale; if (imageWidth <= containerWidth) { + _horizontalDragGestureRecognizer.setAtBothEdges(); return true; } final dx = (1 - _scale) * containerWidth / 2; final dxOffset = (imageWidth - containerWidth) / 2; - if (initialPosition.dx < lastPosition.global.dx) { - return _position.dx.equals(dx + dxOffset, 1e-6); - } else { - return _position.dx.equals(dx - dxOffset, 1e-6); + if (_position.dx.equals(dx + dxOffset, 1e-6)) { + _horizontalDragGestureRecognizer.isAtLeftEdge = true; + return true; } + if (_position.dx.equals(dx - dxOffset, 1e-6)) { + _horizontalDragGestureRecognizer.isAtRightEdge = true; + return true; + } + return false; } void _onPointerSignal(PointerSignalEvent event) { @@ -471,11 +544,3 @@ class _ViewerState extends State with SingleTickerProviderStateMixin { } enum _GestureType { pan, scale, drag } - -double _getFinalTime( - double velocity, - double drag, { - double effectivelyMotionless = 10, -}) { - return math.log(effectivelyMotionless / velocity) / math.log(drag / 100); -}