453 lines
13 KiB
Dart
453 lines
13 KiB
Dart
import 'dart:collection';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/widgets.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 [int] parameter specifies the index of the card to build, and the [BuildContext]
|
|
/// parameter provides the build context. 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 NullableIndexedWidgetBuilder 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.
|
|
///
|
|
/// 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;
|
|
|
|
/// A boolean value that determines whether the card can be swiped horizontally. The default value is true.
|
|
final bool isHorizontalSwipingEnabled;
|
|
|
|
/// A boolean value that determines whether the card can be swiped vertically. The default value is true.
|
|
final bool isVerticalSwipingEnabled;
|
|
|
|
/// 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;
|
|
|
|
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,
|
|
this.onUndo,
|
|
}) : 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;
|
|
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,
|
|
);
|
|
}
|
|
|
|
@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(numberOfCardsOnScreen(), (index) {
|
|
if (index == 0) {
|
|
return _frontItem(constraints);
|
|
}
|
|
if (index == 1) {
|
|
return _secondItem(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!),
|
|
),
|
|
),
|
|
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) _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 _secondItem(BoxConstraints constraints) {
|
|
return Positioned(
|
|
top: _cardAnimation.difference,
|
|
left: 0,
|
|
child: Transform.scale(
|
|
scale: _cardAnimation.scale,
|
|
child: ConstrainedBox(
|
|
constraints: constraints,
|
|
child: widget.cardBuilder(context, _nextIndex!),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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)!,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
void _animationStatusListener(AnimationStatus status) {
|
|
if (status == AnimationStatus.completed) {
|
|
switch (_swipeType) {
|
|
case SwipeType.swipe:
|
|
_handleCompleteSwipe();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
_reset();
|
|
}
|
|
}
|
|
|
|
void _handleCompleteSwipe() {
|
|
final isLastCard = _currentIndex! == widget.cardsCount - 1;
|
|
final shouldCancelSwipe =
|
|
widget.onSwipe?.call(_currentIndex!, _nextIndex, _detectedDirection) ==
|
|
false;
|
|
|
|
if (shouldCancelSwipe) {
|
|
return;
|
|
}
|
|
|
|
_undoableIndex.state = _nextIndex;
|
|
_directionHistory.add(_detectedDirection);
|
|
|
|
if (isLastCard) {
|
|
widget.onEnd?.call();
|
|
}
|
|
}
|
|
|
|
void _reset() {
|
|
setState(() {
|
|
_animationController.reset();
|
|
_cardAnimation.reset();
|
|
_swipeType = SwipeType.none;
|
|
});
|
|
}
|
|
|
|
void _onEndAnimation() {
|
|
if (_cardAnimation.left.abs() > widget.threshold) {
|
|
final direction = _cardAnimation.left.isNegative
|
|
? CardSwiperDirection.left
|
|
: CardSwiperDirection.right;
|
|
_swipe(direction);
|
|
} else if (_cardAnimation.top.abs() > widget.threshold) {
|
|
final direction = _cardAnimation.top.isNegative
|
|
? CardSwiperDirection.top
|
|
: CardSwiperDirection.bottom;
|
|
_swipe(direction);
|
|
} else {
|
|
_goBack();
|
|
}
|
|
}
|
|
|
|
void _swipe(CardSwiperDirection direction) {
|
|
if (!_canSwipe) return;
|
|
|
|
_swipeType = SwipeType.swipe;
|
|
_detectedDirection = direction;
|
|
_cardAnimation.animate(context, direction);
|
|
}
|
|
|
|
void _goBack() {
|
|
_swipeType = SwipeType.back;
|
|
_detectedDirection = CardSwiperDirection.none;
|
|
_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 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;
|
|
}
|
|
}
|