import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; /// Signature used by [swipeable] to indicate that it has been swiped in /// the given `direction`. /// /// Used by [Swipeable.onSwiped]. typedef SwipeDirectionCallback = void Function(SwipeDirection direction); /// Signature for a function that builds a widget given the progress of the /// dismissing action. /// /// Used by [Swipeable.backgroundBuilder]. typedef BackgroundWidgetBuilder = Widget Function( BuildContext context, SwipeUpdateDetails details, ); /// The direction in which a [Swipeable] can be swiped. enum SwipeDirection { /// The [Swipeable] can be swiped by dragging either left or right. horizontal, /// The [Swipeable] can be swiped by dragging in the reverse of the /// reading direction (e.g., from right to left in left-to-right languages). endToStart, /// The [Swipeable] can be swiped by dragging in the reading direction /// (e.g., from left to right in left-to-right languages). startToEnd, /// The [Swipeable] cannot be swiped by dragging. none } /// A widget that can be swiped in a specified direction. /// /// The `Swipeable` widget allows its child to be swiped by the user in a /// specified direction. /// /// It provides options for customizing the swipe behavior, including the /// ability to specify a background widget that appears during the swipe, /// callbacks for handling swipe completion, and more. /// /// Example usage: /// ```dart /// Swipeable( /// child: Container( /// height: 100, /// width: 200, /// color: Colors.blue, /// child: Center( /// child: Text('Swipe me'), /// ), /// ), /// backgroundBuilder: (context, details) { /// final direction = details.direction; /// return Container( /// color: Colors.red, /// child: Center( /// child: Text( /// direction == SwipeDirection.left ? 'Swipe left' : 'Swipe right', /// style: TextStyle( /// color: Colors.white, /// fontWeight: FontWeight.bold, /// ), /// ), /// ), /// ); /// }, /// onSwiped: (direction) { /// if (direction == SwipeDirection.left) { /// // Handle left swipe /// } else if (direction == SwipeDirection.right) { /// // Handle right swipe /// } /// }, /// direction: SwipeDirection.horizontal, /// swipeThreshold: 0.4, /// movementDuration: Duration(milliseconds: 200), /// ) /// ``` class Swipeable extends StatefulWidget { /// Creates a widget that can be swiped . const Swipeable({ required super.key, required this.child, this.backgroundBuilder, this.onSwiped, this.direction = SwipeDirection.horizontal, this.swipeThreshold = 0.4, this.movementDuration = const Duration(milliseconds: 200), this.dragStartBehavior = DragStartBehavior.start, this.behavior = HitTestBehavior.opaque, }) : assert( swipeThreshold >= 0.0 && swipeThreshold <= 1.0, 'swipeThreshold must be between 0.0 and 1.0', ); /// The widget below this widget in the tree. /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; /// A widget that is stacked behind the child. If secondaryBackground is also /// specified then this widget only appears when the child has been dragged /// down or to the right. final BackgroundWidgetBuilder? backgroundBuilder; /// Called when the widget has been successfully swiped based on the /// [direction] and [swipeThreshold]. final SwipeDirectionCallback? onSwiped; /// The direction in which the widget can be swiped. final SwipeDirection direction; /// The offset threshold the item has to be dragged in order to be considered /// swiped. /// /// Represented as a fraction, e.g. if it is 0.4 (the default), then the item /// has to be dragged at least 40% towards one direction to be considered /// swiped. /// /// See also: /// /// * [direction], which controls the directions in which the items can /// be swiped. final double swipeThreshold; /// Defines the duration for card to come back to original position. final Duration movementDuration; /// Determines the way that drag start behavior is handled. /// /// If set to [DragStartBehavior.start], the drag gesture used to dismiss a /// swipeable will begin at the position where the drag gesture won the /// arena. /// /// If set to [DragStartBehavior.down] it will begin at the position where /// a down event is first detected. /// /// In general, setting this to [DragStartBehavior.start] will make drag /// animation smoother and setting it to [DragStartBehavior.down] will make /// drag behavior feel slightly more reactive. /// /// By default, the drag start behavior is [DragStartBehavior.start]. /// /// See also: /// /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for /// the different behaviors. final DragStartBehavior dragStartBehavior; /// How to behave during hit tests. /// /// This defaults to [HitTestBehavior.opaque]. final HitTestBehavior behavior; @override _SwipeableState createState() => _SwipeableState(); } /// Details for [DismissUpdateCallback]. /// /// See also: /// /// * [swipeable.onUpdate], which receives this information. class SwipeUpdateDetails { /// Create a new instance of [SwipeUpdateDetails]. SwipeUpdateDetails({ this.direction = SwipeDirection.horizontal, this.reached = false, this.previousReached = false, this.progress = 0.0, }); /// The direction that the swipeable is being dragged. final SwipeDirection direction; /// Whether the swipe threshold is currently reached. final bool reached; /// Whether the swipe threshold was reached the last time this callback was /// invoked. /// /// This can be used in conjunction with [SwipeUpdateDetails.reached] to catch /// the moment that the [Swipeable] is dragged across the threshold. final bool previousReached; /// The offset ratio of the swipeable in its parent container. /// /// A value of 0.0 represents the normal position and 1.0 means the child is /// completely outside its parent. /// /// This can be used to synchronize other elements to what the swipeable is /// doing on screen, e.g. using this value to set the opacity thereby fading /// swipeable as it's dragged offscreen. final double progress; } class _SwipeableClipper extends CustomClipper { _SwipeableClipper({ required this.moveAnimation, }) : super(reclip: moveAnimation); final Animation moveAnimation; @override Rect getClip(Size size) { final offset = moveAnimation.value.dx * size.width; if (offset < 0) { return Rect.fromLTRB(size.width + offset, 0, size.width, size.height); } return Rect.fromLTRB(0, 0, offset, size.height); } @override Rect getApproximateClipRect(Size size) => getClip(size); @override bool shouldReclip(_SwipeableClipper oldClipper) { return oldClipper.moveAnimation.value != moveAnimation.value; } } class _SwipeableState extends State with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); _moveController = AnimationController( duration: widget.movementDuration, vsync: this, )..addStatusListener((_) => updateKeepAlive()); _updateMoveAnimation(); } AnimationController? _moveController; late Animation _moveAnimation; double _dragExtent = 0; bool _dragUnderway = false; bool _swipeThresholdReached = false; final GlobalKey _contentKey = GlobalKey(); @override bool get wantKeepAlive => _moveController?.isAnimating ?? false; @override void dispose() { _moveController?.dispose(); super.dispose(); } SwipeDirection _extentToDirection(double extent) { if (extent == 0.0) { return SwipeDirection.none; } switch (Directionality.of(context)) { case TextDirection.rtl: return extent < 0 ? SwipeDirection.startToEnd : SwipeDirection.endToStart; case TextDirection.ltr: return extent > 0 ? SwipeDirection.startToEnd : SwipeDirection.endToStart; } } SwipeDirection get _swipeDirection => _extentToDirection(_dragExtent); bool get _isActive => _dragUnderway || _moveController!.isAnimating; double get _overallDragAxisExtent => context.size!.width; void _handleDragStart(DragStartDetails details) { _dragUnderway = true; if (_moveController!.isAnimating) { _dragExtent = _moveController!.value * _overallDragAxisExtent * _dragExtent.sign; _moveController!.stop(); } else { _dragExtent = 0.0; _moveController!.value = 0.0; } setState(_updateMoveAnimation); } void _handleDragUpdate(DragUpdateDetails details) { if (!_isActive || _moveController!.isAnimating) { return; } final delta = details.primaryDelta!; final oldDragExtent = _dragExtent; switch (widget.direction) { case SwipeDirection.horizontal: _dragExtent += delta; break; case SwipeDirection.endToStart: switch (Directionality.of(context)) { case TextDirection.rtl: if (_dragExtent + delta > 0) { _dragExtent += delta; } break; case TextDirection.ltr: if (_dragExtent + delta < 0) { _dragExtent += delta; } break; } break; case SwipeDirection.startToEnd: switch (Directionality.of(context)) { case TextDirection.rtl: if (_dragExtent + delta < 0) { _dragExtent += delta; } break; case TextDirection.ltr: if (_dragExtent + delta > 0) { _dragExtent += delta; } break; } break; case SwipeDirection.none: _dragExtent = 0; break; } if (oldDragExtent.sign != _dragExtent.sign) { setState(_updateMoveAnimation); } if (!_moveController!.isAnimating) { final currentDragExtent = _dragExtent.abs(); final overallDragExtent = _overallDragAxisExtent; final movePastThresholdExtent = widget.swipeThreshold * overallDragExtent; final double newPos; if (currentDragExtent > movePastThresholdExtent) { // How many "thresholds" past the threshold we are. final n = currentDragExtent / movePastThresholdExtent; // Take the number of thresholds past the threshold, and reduce it by // the threshold amount, then normalize it to the drag extents. final reducedThreshold = math.pow(n, 0.3); final adjustedDragExtent = movePastThresholdExtent * reducedThreshold; newPos = adjustedDragExtent / overallDragExtent; } else { newPos = currentDragExtent / overallDragExtent; } _moveController!.value = newPos; } } SwipeUpdateDetails _currentSwipeUpdateDetails() { final oldSwipeThresholdReached = _swipeThresholdReached; _swipeThresholdReached = _moveController!.value > widget.swipeThreshold; return SwipeUpdateDetails( direction: _swipeDirection, reached: _swipeThresholdReached, previousReached: oldSwipeThresholdReached, progress: _moveController!.value, ); } void _updateMoveAnimation() { final end = _dragExtent.sign; _moveAnimation = _moveController!.drive( Tween( begin: Offset.zero, end: Offset(end, 0), ), ); } void _handleDragEnd(DragEndDetails details) { if (!_isActive || _moveController!.isAnimating) return; _dragUnderway = false; // Once dragging ends, animate back to the initial offset. _moveController!.reverse(); // If the threshold was reached, report it. if (_moveController!.value > widget.swipeThreshold) { if (widget.onSwiped != null) { final direction = _swipeDirection; widget.onSwiped!(direction); } } } @override Widget build(BuildContext context) { super.build(context); // See AutomaticKeepAliveClientMixin. assert( debugCheckHasDirectionality(context), 'Swipeable must be inside of a Directionality widget.', ); Widget? background; final backgroundBuilder = widget.backgroundBuilder; if (backgroundBuilder != null) { background = AnimatedBuilder( animation: _moveAnimation, builder: (context, _) { final updateDetails = _currentSwipeUpdateDetails(); return backgroundBuilder.call(context, updateDetails); }, ); } Widget content = SlideTransition( position: _moveAnimation, child: KeyedSubtree(key: _contentKey, child: widget.child), ); if (background != null) { content = Stack( fit: StackFit.passthrough, alignment: Alignment.center, children: [ if (!_moveAnimation.isDismissed) Positioned.fill( child: ClipRect( clipper: _SwipeableClipper( moveAnimation: _moveAnimation, ), child: background, ), ), content, ], ); } // If the SwipeDirection is none, we do not add drag gestures because the // content cannot be dragged. if (widget.direction == SwipeDirection.none) { return content; } // We are not resizing but we may be being dragging in widget.direction. return GestureDetector( onHorizontalDragStart: _handleDragStart, onHorizontalDragUpdate: _handleDragUpdate, onHorizontalDragEnd: _handleDragEnd, behavior: widget.behavior, dragStartBehavior: widget.dragStartBehavior, child: content, ); } }