diff --git a/lib/frontend/widgets/rightward_drag_recognizer.dart b/lib/frontend/widgets/rightward_drag_recognizer.dart new file mode 100644 index 0000000..c63fcbb --- /dev/null +++ b/lib/frontend/widgets/rightward_drag_recognizer.dart @@ -0,0 +1,81 @@ +import 'package:flutter/gestures.dart'; + +class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { + RightwardDragRecognizer({super.debugOwner}) { + onlyAcceptDragOnThreshold = true; + } + + static const double _kMinAcceptVelocity = 700.0; + static const double _kMinAcceptDistance = 20.0; + + final Map _initialPositions = {}; + final Map _velocityTrackers = {}; + final Map _currentDeltaX = {}; + + @override + void addAllowedPointer(PointerDownEvent event) { + _initialPositions[event.pointer] = event.position; + final tracker = VelocityTracker.withKind(event.kind); + tracker.addPosition(event.timeStamp, event.localPosition); + _velocityTrackers[event.pointer] = tracker; + super.addAllowedPointer(event); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) { + _velocityTrackers[event.pointer] + ?.addPosition(event.timeStamp, event.localPosition); + final initial = _initialPositions[event.pointer]; + if (initial != null) { + final dx = event.position.dx - initial.dx; + _currentDeltaX[event.pointer] = dx; + if (dx < -kTouchSlop) { + stopTrackingPointer(event.pointer); + _cleanup(event.pointer); + return; + } + } + } + super.handleEvent(event); + } + + @override + bool hasSufficientGlobalDistanceToAccept( + PointerDeviceKind pointerDeviceKind, + double? deviceTouchSlop, + ) { + if (!super.hasSufficientGlobalDistanceToAccept( + pointerDeviceKind, deviceTouchSlop)) { + return false; + } + double maxDx = 0; + for (final dx in _currentDeltaX.values) { + if (dx > maxDx) maxDx = dx; + } + if (maxDx < _kMinAcceptDistance) return false; + for (final tracker in _velocityTrackers.values) { + final vx = tracker.getVelocity().pixelsPerSecond.dx; + if (vx >= _kMinAcceptVelocity) return true; + } + return false; + } + + void _cleanup(int pointer) { + _initialPositions.remove(pointer); + _velocityTrackers.remove(pointer); + _currentDeltaX.remove(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + _cleanup(pointer); + super.didStopTrackingLastPointer(pointer); + } + + @override + void rejectGesture(int pointer) { + _cleanup(pointer); + super.rejectGesture(pointer); + } +} diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart index 933ad50..2927d9f 100644 --- a/lib/frontend/widgets/swipe_route.dart +++ b/lib/frontend/widgets/swipe_route.dart @@ -1,12 +1,101 @@ +import 'dart:math' as math; +import 'dart:ui'; + import 'package:flutter/cupertino.dart'; -class SwipeRoute extends CupertinoPageRoute { +import 'rightward_drag_recognizer.dart'; + +class SwipeRoute extends PageRoute { SwipeRoute({ - required super.builder, + required this.builder, super.settings, - super.maintainState, super.fullscreenDialog, + this.maintainState = true, }); + + final WidgetBuilder builder; + + @override + final bool maintainState; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 400); + + @override + Duration get reverseTransitionDuration => const Duration(milliseconds: 400); + + @override + bool canTransitionTo(TransitionRoute nextRoute) { + return nextRoute is SwipeRoute || nextRoute is CupertinoRouteTransitionMixin; + } + + @override + bool get popGestureInProgress => _gestureController != null; + + _SwipeBackController? _gestureController; + + @override + bool get popGestureEnabled { + if (isFirst) return false; + if (willHandlePopInternally) return false; + if (popDisposition == RoutePopDisposition.doNotPop) return false; + if (animation?.status != AnimationStatus.completed) return false; + if (secondaryAnimation?.status != AnimationStatus.dismissed) return false; + if (popGestureInProgress) return false; + return true; + } + + _SwipeBackController _startPopGesture() { + final gesture = _SwipeBackController( + navigator: navigator!, + controller: controller!, + ); + _gestureController = gesture; + gesture._onEnd = () { + if (_gestureController == gesture) { + _gestureController = null; + } + }; + return gesture; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: builder(context), + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return _SwipeBackGestureDetector( + enabledCallback: () => popGestureEnabled, + onStartPopGesture: _startPopGesture, + child: CupertinoPageTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: popGestureInProgress, + child: child, + ), + ); + } } Future pushSwipeable( @@ -18,3 +107,130 @@ Future pushSwipeable( SwipeRoute(builder: builder, settings: settings), ); } + +class _SwipeBackGestureDetector extends StatefulWidget { + const _SwipeBackGestureDetector({ + required this.enabledCallback, + required this.onStartPopGesture, + required this.child, + }); + + final ValueGetter enabledCallback; + final ValueGetter<_SwipeBackController> onStartPopGesture; + final Widget child; + + @override + State<_SwipeBackGestureDetector> createState() => + _SwipeBackGestureDetectorState(); +} + +class _SwipeBackGestureDetectorState + extends State<_SwipeBackGestureDetector> { + _SwipeBackController? _backController; + double _width = 0; + + void _handleStart(DragStartDetails details) { + if (!widget.enabledCallback()) return; + _width = context.size?.width ?? MediaQuery.of(context).size.width; + if (_width <= 0) _width = 1.0; + _backController = widget.onStartPopGesture(); + } + + void _handleUpdate(DragUpdateDetails details) { + final delta = details.primaryDelta ?? 0.0; + _backController?.dragUpdate(delta / _width); + } + + void _handleEnd(DragEndDetails details) { + final velocity = details.velocity.pixelsPerSecond.dx / _width; + _backController?.dragEnd(velocity); + _backController = null; + } + + void _handleCancel() { + _backController?.dragEnd(0.0); + _backController = null; + } + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + RightwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _handleStart + ..onUpdate = _handleUpdate + ..onEnd = _handleEnd + ..onCancel = _handleCancel; + }, + ), + }, + child: widget.child, + ); + } +} + +class _SwipeBackController { + _SwipeBackController({ + required this.navigator, + required this.controller, + }); + + final NavigatorState navigator; + final AnimationController controller; + VoidCallback? _onEnd; + + static const double _kMinFlingVelocity = 1.0; + + void dragUpdate(double delta) { + controller.value -= delta; + } + + void dragEnd(double velocity) { + const animationCurve = Curves.fastLinearToSlowEaseIn; + final bool animateForward; + + if (velocity.abs() >= _kMinFlingVelocity) { + animateForward = velocity <= 0; + } else { + animateForward = controller.value > 0.5; + } + + if (animateForward) { + final forwardMs = math.min( + lerpDouble(800, 0, controller.value)!.floor(), + 300, + ); + controller.animateTo( + 1.0, + duration: Duration(milliseconds: forwardMs), + curve: animationCurve, + ); + } else { + navigator.pop(); + if (controller.isAnimating) { + final backMs = lerpDouble(0, 800, controller.value)!.floor(); + controller.animateBack( + 0.0, + duration: Duration(milliseconds: backMs), + curve: animationCurve, + ); + } + } + + if (controller.isAnimating) { + late AnimationStatusListener statusCb; + statusCb = (status) { + _onEnd?.call(); + controller.removeStatusListener(statusCb); + }; + controller.addStatusListener(statusCb); + } else { + _onEnd?.call(); + } + } +} diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart index 3c14093..18ce6ff 100644 --- a/lib/frontend/widgets/swipe_to_pop.dart +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -1,22 +1,19 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'rightward_drag_recognizer.dart'; + class SwipeToPop extends StatefulWidget { final Widget child; - final double edgeWidth; final double popThreshold; final double velocityThreshold; - final bool fullWidth; final bool enabled; final VoidCallback? onPop; const SwipeToPop({ super.key, required this.child, - this.edgeWidth = 28, this.popThreshold = 0.35, this.velocityThreshold = 700, - this.fullWidth = false, this.enabled = true, this.onPop, }); @@ -28,6 +25,7 @@ class SwipeToPop extends StatefulWidget { class _SwipeToPopState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + double _width = 0; @override void initState() { @@ -53,17 +51,19 @@ class _SwipeToPopState extends State } void _onDragStart(DragStartDetails _) { + _width = context.size?.width ?? MediaQuery.of(context).size.width; + if (_width <= 0) _width = 1.0; _controller.stop(); } - void _onDragUpdate(DragUpdateDetails d, double width) { - if (width <= 0) return; - final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0); + void _onDragUpdate(DragUpdateDetails d) { + final next = + (_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp(0.0, 1.0); _controller.value = next; } - Future _onDragEnd(DragEndDetails d, double width) async { - final velocity = d.primaryVelocity ?? 0; + Future _onDragEnd(DragEndDetails d) async { + final velocity = d.velocity.pixelsPerSecond.dx; final pastThreshold = _controller.value > widget.popThreshold || velocity > widget.velocityThreshold; if (pastThreshold) { @@ -96,44 +96,35 @@ class _SwipeToPopState extends State return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - final gestureChild = GestureDetector( + return RawGestureDetector( behavior: HitTestBehavior.translucent, - dragStartBehavior: DragStartBehavior.down, - onHorizontalDragStart: _onDragStart, - onHorizontalDragUpdate: (d) => _onDragUpdate(d, width), - onHorizontalDragEnd: (d) => _onDragEnd(d, width), - onHorizontalDragCancel: _onDragCancel, - ); - - return Stack( - children: [ - Positioned.fill( - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - final t = _controller.value; - return Transform.translate( - offset: Offset(t * width, 0), - child: Opacity( - opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), - child: child, - ), - ); - }, - child: widget.child, - ), + gestures: { + RightwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel; + }, ), - if (widget.fullWidth) - Positioned.fill(child: gestureChild) - else - Positioned( - left: 0, - top: 0, - bottom: 0, - width: widget.edgeWidth, - child: gestureChild, - ), - ], + }, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + return Transform.translate( + offset: Offset(t * width, 0), + child: Opacity( + opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: widget.child, + ), ); }, );