import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:flutter_card_swiper/src/card_swiper_controller.dart'; import 'package:flutter_card_swiper/src/enums.dart'; import 'package:flutter_card_swiper/src/extensions.dart'; import 'package:flutter_card_swiper/src/typedefs.dart'; class CardSwiper extends StatefulWidget { /// widget builder for rendering cards final NullableIndexedWidgetBuilder cardBuilder; /// cards count final int cardsCount; /// index of the first card when the swiper is initialized final int initialIndex; /// controller to trigger actions final CardSwiperController? controller; /// duration of every animation final Duration duration; /// padding of the swiper final EdgeInsetsGeometry padding; /// maximum angle the card reaches while swiping final double maxAngle; /// threshold from which the card is swiped away final int threshold; /// scale of the card that is behind the front card final double scale; /// set to true if swiping should be disabled, exception: triggered from the outside final bool isDisabled; /// function that gets called with the new index and detected swipe direction when the user swiped or swipe is triggered by controller /// /// If [onSwipe] returns false, the swipe action will be canceled and the current card will remain on top of the stack. /// Otherwise, if it returns true, the swipe action will be performed as expected. final CardSwiperOnSwipe? onSwipe; /// function that gets called when there is no widget left to be swiped away final CardSwiperOnEnd? onEnd; /// function that gets triggered when the swiper is disabled final CardSwiperOnTapDisabled? onTapDisabled; /// direction in which the card gets swiped when triggered by controller, default set to right final CardSwiperDirection direction; /// set to false if you want your card to move only across the vertical axis when swiping final bool isHorizontalSwipingEnabled; /// set to false if you want your card to move only across the horizontal axis when swiping final bool isVerticalSwipingEnabled; /// set to true if the stack should loop final bool isLoop; /// here you can change the number of cards that are displayed at the same time final int numberOfCardsDisplayed; const CardSwiper({ Key? key, required this.cardBuilder, required this.cardsCount, this.controller, this.initialIndex = 0, this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 25), this.duration = const Duration(milliseconds: 200), this.maxAngle = 30, this.threshold = 50, this.scale = 0.9, this.isDisabled = false, this.onTapDisabled, this.onSwipe, this.onEnd, this.direction = CardSwiperDirection.right, this.isHorizontalSwipingEnabled = true, this.isVerticalSwipingEnabled = true, this.isLoop = true, this.numberOfCardsDisplayed = 2, }) : assert( maxAngle >= 0 && maxAngle <= 360, 'maxAngle must be between 0 and 360', ), assert( threshold >= 1 && threshold <= 100, 'threshold must be between 1 and 100', ), assert( direction != CardSwiperDirection.none, 'direction must not be none', ), assert( scale >= 0 && scale <= 1, 'scale must be between 0 and 1', ), assert( numberOfCardsDisplayed >= 1 && numberOfCardsDisplayed <= cardsCount, 'you must display at least one card, and no more than the length of cards parameter', ), assert( initialIndex >= 0 && initialIndex < cardsCount, 'initialIndex must be between 0 and cardsCount', ), super(key: key); @override State createState() => _CardSwiperState(); } class _CardSwiperState extends State with SingleTickerProviderStateMixin { double _left = 0; double _top = 0; double _total = 0; double _angle = 0; late double _scale = widget.scale; double _difference = 40; SwipeType _swipeType = SwipeType.none; bool _tapOnTop = false; //position of starting drag point on card late AnimationController _animationController; late Animation _leftAnimation; late Animation _topAnimation; late Animation _scaleAnimation; late Animation _differenceAnimation; CardSwiperDirection detectedDirection = CardSwiperDirection.none; double get _maxAngle => widget.maxAngle * (pi / 180); int? _currentIndex; int? get _nextIndex => getValidIndexOffset(1); bool get _canSwipe => _currentIndex != null && !widget.isDisabled; @override void initState() { super.initState(); _currentIndex = widget.initialIndex; widget.controller?.addListener(_controllerListener); _animationController = AnimationController( duration: widget.duration, vsync: this, ) ..addListener(_animationListener) ..addStatusListener(_animationStatusListener); } @override void dispose() { super.dispose(); _animationController.dispose(); widget.controller?.dispose(); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return Padding( padding: widget.padding, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return Stack( clipBehavior: Clip.none, fit: StackFit.expand, children: List.generate(nbOfCardsOnScreen(), (index) { if (index == 0) { return _frontItem(constraints); } if (index == 1) { return _secondItem(constraints); } return _backItem(constraints, index); }).reversed.toList(), ); }, ), ); }, ); } /// The card shown at the front of the stack, that can be dragged and swipped Widget _frontItem(BoxConstraints constraints) { return Positioned( left: _left, top: _top, child: GestureDetector( child: Transform.rotate( angle: _angle, child: ConstrainedBox( constraints: constraints, child: widget.cardBuilder(context, _currentIndex!), ), ), onTap: () { if (widget.isDisabled) { widget.onTapDisabled?.call(); } }, onPanStart: (tapInfo) { if (!widget.isDisabled) { final renderBox = context.findRenderObject()! as RenderBox; final position = renderBox.globalToLocal(tapInfo.globalPosition); if (position.dy < renderBox.size.height / 2) _tapOnTop = true; } }, onPanUpdate: (tapInfo) { if (!widget.isDisabled) { setState(() { if (widget.isHorizontalSwipingEnabled) { _left += tapInfo.delta.dx; } if (widget.isVerticalSwipingEnabled) { _top += tapInfo.delta.dy; } _total = _left + _top; _calculateAngle(); _calculateScale(); _calculateDifference(); }); } }, onPanEnd: (tapInfo) { if (_canSwipe) { _tapOnTop = false; _onEndAnimation(); _animationController.forward(); } }, ), ); } /// the card that is just behind the _frontItem, only moves to take its place /// during a movement of _frontItem Widget _secondItem(BoxConstraints constraints) { return Positioned( top: _difference, left: 0, child: Transform.scale( scale: _scale, child: ConstrainedBox( constraints: constraints, child: widget.cardBuilder(context, _nextIndex!), ), ), ); } /// if widget.numberOfCardsDisplayed > 2, those cards are built behind the /// _secondItem and can't move at all Widget _backItem(BoxConstraints constraints, int offset) { return Positioned( top: 40, left: 0, child: Transform.scale( scale: widget.scale, child: ConstrainedBox( constraints: constraints, child: widget.cardBuilder( context, getValidIndexOffset(offset)!, ), ), ), ); } //swipe widget from the outside void _controllerListener() { switch (widget.controller!.state) { case CardSwiperState.swipe: _swipe(context, widget.direction); break; case CardSwiperState.swipeLeft: _swipe(context, CardSwiperDirection.left); break; case CardSwiperState.swipeRight: _swipe(context, CardSwiperDirection.right); break; case CardSwiperState.swipeTop: _swipe(context, CardSwiperDirection.top); break; case CardSwiperState.swipeBottom: _swipe(context, CardSwiperDirection.bottom); break; default: break; } } //when value of controller changes void _animationListener() { if (_animationController.status == AnimationStatus.forward) { setState(() { _left = _leftAnimation.value; _top = _topAnimation.value; _scale = _scaleAnimation.value; _difference = _differenceAnimation.value; }); } } // handle the onSwipe methode as well as removing the current card from the // stack if onSwipe does not return false void _handleOnSwipe() { setState(() { if (_swipeType == SwipeType.swipe) { final shouldCancelSwipe = widget.onSwipe ?.call(_currentIndex, _nextIndex, detectedDirection) == false; if (shouldCancelSwipe) { return; } final previousIndex = _currentIndex; final isLastCard = _currentIndex == widget.cardsCount - 1; _currentIndex = _nextIndex; widget.onSwipe?.call( previousIndex, _currentIndex, detectedDirection, ); if (isLastCard) { widget.onEnd?.call(); } } }); } // reset the card animation void _resetCardAnimation() { setState(() { _animationController.reset(); _left = 0; _top = 0; _total = 0; _angle = 0; _scale = widget.scale; _difference = 40; _swipeType = SwipeType.none; }); } //when the status of animation changes void _animationStatusListener(AnimationStatus status) { if (status == AnimationStatus.completed) { _handleOnSwipe(); _resetCardAnimation(); } } void _calculateAngle() { if (_angle <= _maxAngle && _angle >= -_maxAngle) { _angle = (_maxAngle / 100) * (_left / 10); if (_tapOnTop) _angle *= -1; } } void _calculateScale() { if (_scale <= 1.0 && _scale >= widget.scale) { _scale = (_total > 0) ? widget.scale + (_total / 5000) : widget.scale + -1 * (_total / 5000); } } void _calculateDifference() { if (_difference >= 0 && _difference <= _difference) { _difference = (_total > 0) ? 40 - (_total / 10) : 40 + (_total / 10); } } void _onEndAnimation() { if (_left < -widget.threshold || _left > widget.threshold) { _swipeHorizontal(context); } else if (_top < -widget.threshold || _top > widget.threshold) { _swipeVertical(context); } else { _goBack(context); } } void _swipe(BuildContext context, CardSwiperDirection direction) { if (!_canSwipe) return; switch (direction) { case CardSwiperDirection.left: _left = -1; _swipeHorizontal(context); break; case CardSwiperDirection.right: _left = widget.threshold + 1; _swipeHorizontal(context); break; case CardSwiperDirection.top: _top = -1; _swipeVertical(context); break; case CardSwiperDirection.bottom: _top = widget.threshold + 1; _swipeVertical(context); break; default: break; } _animationController.forward(); } //moves the card away to the left or right void _swipeHorizontal(BuildContext context) { _leftAnimation = Tween( begin: _left, end: (_left == 0 && widget.direction == CardSwiperDirection.right) || _left > widget.threshold ? MediaQuery.of(context).size.width : -MediaQuery.of(context).size.width, ).animate(_animationController); _topAnimation = Tween( begin: _top, end: _top + _top, ).animate(_animationController); _scaleAnimation = Tween( begin: _scale, end: 1.0, ).animate(_animationController); _differenceAnimation = Tween( begin: _difference, end: 0, ).animate(_animationController); _swipeType = SwipeType.swipe; if (_left > widget.threshold || _left == 0 && widget.direction == CardSwiperDirection.right) { detectedDirection = CardSwiperDirection.right; } else { detectedDirection = CardSwiperDirection.left; } } //moves the card away to the top or bottom void _swipeVertical(BuildContext context) { _leftAnimation = Tween( begin: _left, end: _left + _left, ).animate(_animationController); _topAnimation = Tween( begin: _top, end: (_top == 0 && widget.direction == CardSwiperDirection.bottom) || _top > widget.threshold ? MediaQuery.of(context).size.height : -MediaQuery.of(context).size.height, ).animate(_animationController); _scaleAnimation = Tween( begin: _scale, end: 1.0, ).animate(_animationController); _differenceAnimation = Tween( begin: _difference, end: 0, ).animate(_animationController); _swipeType = SwipeType.swipe; if (_top > widget.threshold || _top == 0 && widget.direction == CardSwiperDirection.bottom) { detectedDirection = CardSwiperDirection.bottom; } else { detectedDirection = CardSwiperDirection.top; } } //moves the card back to starting position void _goBack(BuildContext context) { _leftAnimation = Tween( begin: _left, end: 0, ).animate(_animationController); _topAnimation = Tween( begin: _top, end: 0, ).animate(_animationController); _scaleAnimation = Tween( begin: _scale, end: widget.scale, ).animate(_animationController); _differenceAnimation = Tween( begin: _difference, end: 40, ).animate(_animationController); _swipeType = SwipeType.back; } ///the number of cards that are built on the screen int nbOfCardsOnScreen() { if (widget.isLoop) { return widget.numberOfCardsDisplayed; } if (_currentIndex == null) { return 0; } return min( widget.numberOfCardsDisplayed, widget.cardsCount - _currentIndex!, ); } int? getValidIndexOffset(int offset) { if (_currentIndex == null) { return null; } final index = _currentIndex! + offset; if (!widget.isLoop && !index.isBetween(0, widget.cardsCount)) { return null; } return index % widget.cardsCount; } }