f_card_swiper/lib/src/card_swiper.dart

511 lines
16 KiB
Dart

import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:flutter_card_swiper/src/allowed_swipe_direction.dart';
import 'package:flutter_card_swiper/src/card_animation.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';
import 'package:flutter_card_swiper/src/undoable.dart';
class CardSwiper extends StatefulWidget {
/// Function that builds each card in the stack.
///
/// The function is called with the index of the card to be built, the build context, the ratio
/// of vertical drag to [threshold] as a percentage, and the ratio of horizontal drag to [threshold]
/// as a percentage. The function should return a widget that represents the card at the given index.
/// It can return `null`, which will result in an empty card being displayed.
final NullableCardBuilder cardBuilder;
/// The number of cards in the stack.
///
/// The [cardsCount] parameter specifies the number of cards that will be displayed in the stack.
///
/// This parameter is required and must be greater than 0.
final int cardsCount;
/// The index of the card to display initially.
///
/// Defaults to 0, meaning the first card in the stack is displayed initially.
final int initialIndex;
/// The [CardSwiperController] used to control the swiper externally.
///
/// If `null`, the swiper can only be controlled by user input.
final CardSwiperController? controller;
/// The duration of each swipe animation.
///
/// Defaults to 200 milliseconds.
final Duration duration;
/// The padding around the swiper.
///
/// Defaults to `EdgeInsets.symmetric(horizontal: 20, vertical: 25)`.
final EdgeInsetsGeometry padding;
/// The maximum angle the card reaches while swiping.
///
/// Must be between 0 and 360 degrees. Defaults to 30 degrees.
final double maxAngle;
/// The threshold from which the card is swiped away.
///
/// Must be between 1 and 100 percent of the card width. Defaults to 50 percent.
final int threshold;
/// The scale of the card that is behind the front card.
///
/// The [scale] and [backCardOffset] both impact the positions of the back cards.
/// In order to keep the back card position same after changing the [scale],
/// the [backCardOffset] should also be adjusted.
/// * As a rough rule of thumb, 0.1 change in [scale] effects an
/// [backCardOffset] of ~35px.
///
/// Must be between 0 and 1. Defaults to 0.9.
final double scale;
/// Whether swiping is disabled.
///
/// If `true`, swiping is disabled, except when triggered by the [controller].
///
/// Defaults to `false`.
final bool isDisabled;
/// Callback function that is called when a swipe action is performed.
///
/// The function is called with the oldIndex, the currentIndex and the direction of the swipe.
/// If the function returns `false`, the swipe action is canceled and the current card remains
/// on top of the stack. If the function returns `true`, the swipe action is performed as expected.
final CardSwiperOnSwipe? onSwipe;
/// Callback function that is called when there are no more cards to swipe.
final CardSwiperOnEnd? onEnd;
/// Callback function that is called when the swiper is disabled.
final CardSwiperOnTapDisabled? onTapDisabled;
/// The direction in which the card is swiped when triggered by the [controller].
///
/// Defaults to [CardSwiperDirection.right].
final CardSwiperDirection direction;
/// Defined the directions in which the card is allowed to be swiped.
/// Defaults to [AllowedSwipeDirection.all]
final AllowedSwipeDirection allowedSwipeDirection;
/// A boolean value that determines whether the card stack should loop. When the last card is swiped,
/// if isLoop is true, the first card will become the last card again. The default value is true.
final bool isLoop;
/// An integer that determines the number of cards that are displayed at the same time.
/// The default value is 2. Note that you must display at least one card, and no more than the [cardsCount] parameter.
final int numberOfCardsDisplayed;
/// Callback function that is called when a card is unswiped.
///
/// The function is called with the oldIndex, the currentIndex and the direction of the previous swipe.
/// If the function returns `false`, the undo action is canceled and the current card remains
/// on top of the stack. If the function returns `true`, the undo action is performed as expected.
final CardSwiperOnUndo? onUndo;
/// Callback function that is called when a card swipe direction changes.
///
/// The function is called with the last detected horizontal direction and the last detected vertical direction
final CardSwiperDirectionChange? onSwipeDirectionChange;
/// The offset of the back card from the front card.
///
/// In order to keep the back card position same after changing the [backCardOffset],
/// the [scale] should also be adjusted.
/// * As a rough rule of thumb, 35px change in [backCardOffset] effects a
/// [scale] change of 0.1.
///
/// Must be a positive value. Defaults to Offset(0, 40).
final Offset backCardOffset;
const CardSwiper({
required this.cardBuilder,
required this.cardsCount,
Key? key,
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.onSwipeDirectionChange,
this.allowedSwipeDirection = const AllowedSwipeDirection.all(),
this.isLoop = true,
this.numberOfCardsDisplayed = 2,
this.onUndo,
this.backCardOffset = const Offset(0, 40),
}) : 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 [cardsCount]',
),
assert(
initialIndex >= 0 && initialIndex < cardsCount,
'initialIndex must be between 0 and [cardsCount]',
),
super(key: key);
@override
State createState() => _CardSwiperState();
}
class _CardSwiperState<T extends Widget> extends State<CardSwiper>
with SingleTickerProviderStateMixin {
late CardAnimation _cardAnimation;
late AnimationController _animationController;
SwipeType _swipeType = SwipeType.none;
CardSwiperDirection _detectedDirection = CardSwiperDirection.none;
CardSwiperDirection _detectedHorizontalDirection = CardSwiperDirection.none;
CardSwiperDirection _detectedVerticalDirection = CardSwiperDirection.none;
bool _tappedOnTop = false;
final _undoableIndex = Undoable<int?>(null);
final Queue<CardSwiperDirection> _directionHistory = Queue();
int? get _currentIndex => _undoableIndex.state;
int? get _nextIndex => getValidIndexOffset(1);
bool get _canSwipe => _currentIndex != null && !widget.isDisabled;
@override
void initState() {
super.initState();
_undoableIndex.state = widget.initialIndex;
widget.controller?.addListener(_controllerListener);
_animationController = AnimationController(
duration: widget.duration,
vsync: this,
)
..addListener(_animationListener)
..addStatusListener(_animationStatusListener);
_cardAnimation = CardAnimation(
animationController: _animationController,
maxAngle: widget.maxAngle,
initialScale: widget.scale,
allowedSwipeDirection: widget.allowedSwipeDirection,
initialOffset: widget.backCardOffset,
onSwipeDirectionChanged: onSwipeDirectionChanged,
);
}
void onSwipeDirectionChanged(CardSwiperDirection direction) {
if (direction == CardSwiperDirection.none) {
_detectedVerticalDirection = direction;
_detectedHorizontalDirection = direction;
widget.onSwipeDirectionChange
?.call(_detectedHorizontalDirection, _detectedVerticalDirection);
} else if (direction == CardSwiperDirection.right ||
direction == CardSwiperDirection.left) {
if (_detectedHorizontalDirection != direction) {
_detectedHorizontalDirection = direction;
widget.onSwipeDirectionChange
?.call(_detectedHorizontalDirection, _detectedVerticalDirection);
}
} else if (direction == CardSwiperDirection.top ||
direction == CardSwiperDirection.bottom) {
if (_detectedVerticalDirection != direction) {
_detectedVerticalDirection = direction;
widget.onSwipeDirectionChange
?.call(_detectedHorizontalDirection, _detectedVerticalDirection);
}
}
}
@override
void dispose() {
_animationController.dispose();
widget.controller?.removeListener(_controllerListener);
super.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(numberOfCardsOnScreen(), (index) {
if (index == 0) return _frontItem(constraints);
return _backItem(constraints, index);
}).reversed.toList(),
);
},
),
);
},
);
}
Widget _frontItem(BoxConstraints constraints) {
return Positioned(
left: _cardAnimation.left,
top: _cardAnimation.top,
child: GestureDetector(
child: Transform.rotate(
angle: _cardAnimation.angle,
child: ConstrainedBox(
constraints: constraints,
child: widget.cardBuilder(
context,
_currentIndex!,
(100 * _cardAnimation.left / widget.threshold).ceil(),
(100 * _cardAnimation.top / widget.threshold).ceil(),
),
),
),
onTap: () async {
if (widget.isDisabled) {
await 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) _tappedOnTop = true;
}
},
onPanUpdate: (tapInfo) {
if (!widget.isDisabled) {
setState(
() => _cardAnimation.update(
tapInfo.delta.dx,
tapInfo.delta.dy,
_tappedOnTop,
),
);
}
},
onPanEnd: (tapInfo) {
if (_canSwipe) {
_tappedOnTop = false;
_onEndAnimation();
}
},
),
);
}
Widget _backItem(BoxConstraints constraints, int index) {
return Positioned(
top: (widget.backCardOffset.dy * index) - _cardAnimation.difference.dy,
left: (widget.backCardOffset.dx * index) - _cardAnimation.difference.dx,
child: Transform.scale(
scale: _cardAnimation.scale - ((1 - widget.scale) * (index - 1)),
child: ConstrainedBox(
constraints: constraints,
child: widget.cardBuilder(context, getValidIndexOffset(index)!, 0, 0),
),
),
);
}
void _controllerListener() {
switch (widget.controller?.state) {
case CardSwiperState.swipe:
return _swipe(widget.direction);
case CardSwiperState.swipeLeft:
return _swipe(CardSwiperDirection.left);
case CardSwiperState.swipeRight:
return _swipe(CardSwiperDirection.right);
case CardSwiperState.swipeTop:
return _swipe(CardSwiperDirection.top);
case CardSwiperState.swipeBottom:
return _swipe(CardSwiperDirection.bottom);
case CardSwiperState.undo:
return _undo();
default:
return;
}
}
void _animationListener() {
if (_animationController.status == AnimationStatus.forward) {
setState(_cardAnimation.sync);
}
}
Future<void> _animationStatusListener(AnimationStatus status) async {
if (status == AnimationStatus.completed) {
switch (_swipeType) {
case SwipeType.swipe:
await _handleCompleteSwipe();
break;
default:
break;
}
_reset();
}
}
Future<void> _handleCompleteSwipe() async {
final isLastCard = _currentIndex! == widget.cardsCount - 1;
final shouldCancelSwipe = await widget.onSwipe
?.call(_currentIndex!, _nextIndex, _detectedDirection) ==
false;
if (shouldCancelSwipe) {
return;
}
_undoableIndex.state = _nextIndex;
_directionHistory.add(_detectedDirection);
if (isLastCard) {
widget.onEnd?.call();
}
}
void _reset() {
onSwipeDirectionChanged(CardSwiperDirection.none);
_detectedDirection = CardSwiperDirection.none;
setState(() {
_animationController.reset();
_cardAnimation.reset();
_swipeType = SwipeType.none;
});
}
void _onEndAnimation() {
final direction = _getEndAnimationDirection();
final isValidDirection = this._isValidDirection(direction);
if (isValidDirection) {
_swipe(direction);
} else {
_goBack();
}
}
CardSwiperDirection _getEndAnimationDirection() {
if (_cardAnimation.left.abs() > widget.threshold) {
return _cardAnimation.left.isNegative
? CardSwiperDirection.left
: CardSwiperDirection.right;
}
if (_cardAnimation.top.abs() > widget.threshold) {
return _cardAnimation.top.isNegative
? CardSwiperDirection.top
: CardSwiperDirection.bottom;
}
return CardSwiperDirection.none;
}
bool _isValidDirection(CardSwiperDirection direction) {
switch (direction) {
case CardSwiperDirection.left:
return widget.allowedSwipeDirection.left;
case CardSwiperDirection.right:
return widget.allowedSwipeDirection.right;
case CardSwiperDirection.top:
return widget.allowedSwipeDirection.up;
case CardSwiperDirection.bottom:
return widget.allowedSwipeDirection.down;
default:
return false;
}
}
void _swipe(CardSwiperDirection direction) {
if (_currentIndex == null) return;
_swipeType = SwipeType.swipe;
_detectedDirection = direction;
_cardAnimation.animate(context, direction);
}
void _goBack() {
_swipeType = SwipeType.back;
_cardAnimation.animateBack(context);
}
void _undo() {
if (_directionHistory.isEmpty) return;
if (_undoableIndex.previousState == null) return;
final direction = _directionHistory.last;
final shouldCancelUndo = widget.onUndo?.call(
_currentIndex,
_undoableIndex.previousState!,
direction,
) ==
false;
if (shouldCancelUndo) {
return;
}
_undoableIndex.undo();
_directionHistory.removeLast();
_swipeType = SwipeType.undo;
_cardAnimation.animateUndo(context, direction);
}
int numberOfCardsOnScreen() {
if (widget.isLoop) {
return widget.numberOfCardsDisplayed;
}
if (_currentIndex == null) {
return 0;
}
return math.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 - 1)) {
return null;
}
return index % widget.cardsCount;
}
}