feat: add undo feature (#1)
This commit is contained in:
parent
23c5e9f0d3
commit
b5bb8f5c9a
|
|
@ -1,3 +1,7 @@
|
|||
## [4.1.0]
|
||||
|
||||
- Adds option to undo swipes.
|
||||
|
||||
## [4.0.2]
|
||||
|
||||
- Fixes `onSwipe` callback being called twice.
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ class Example extends StatelessWidget {
|
|||
| isLoop | true | Set to `true` if the stack should loop | false |
|
||||
| onTapDisabled | - | Function that get triggered when the swiper is disabled | false |
|
||||
| onSwipe | - | Function that is called when the user swipes a card. If the function returns `false`, the swipe action is canceled. If it returns `true`, the swipe action is performed as expected | false |
|
||||
| onUndo | - | Function that is called when the controller calls undo. If the function returns `false`, the undo action is canceled. If it returns `true`, the undo action is performed as expected | false |
|
||||
| onEnd | - | Function that is called when there are no more cards left to swipe | false |
|
||||
| direction | right | Direction in which the card is swiped away when triggered from the outside | false |
|
||||
| numberOfCardsDisplayed | 2 | Number of cards to display at a time | false |
|
||||
|
|
|
|||
|
|
@ -38,15 +38,20 @@ class _ExamplePageState extends State<Example> {
|
|||
cardsCount: cards.length,
|
||||
numberOfCardsDisplayed: 3,
|
||||
onSwipe: _onSwipe,
|
||||
onUndo: _onUndo,
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
cardBuilder: (context, index) => cards[index],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
onPressed: controller.undo,
|
||||
child: const Icon(Icons.rotate_left),
|
||||
),
|
||||
FloatingActionButton(
|
||||
onPressed: controller.swipe,
|
||||
child: const Icon(Icons.rotate_right),
|
||||
|
|
@ -77,12 +82,23 @@ class _ExamplePageState extends State<Example> {
|
|||
}
|
||||
|
||||
bool _onSwipe(
|
||||
int? previousIndex,
|
||||
int previousIndex,
|
||||
int? currentIndex,
|
||||
CardSwiperDirection direction,
|
||||
) {
|
||||
debugPrint(
|
||||
'the card $previousIndex was swiped to the ${direction.name}. Now the card $currentIndex is on top',
|
||||
'The card $previousIndex was swiped to the ${direction.name}. Now the card $currentIndex is on top',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _onUndo(
|
||||
int? previousIndex,
|
||||
int currentIndex,
|
||||
CardSwiperDirection direction,
|
||||
) {
|
||||
debugPrint(
|
||||
'The card $currentIndex was undod from the ${direction.name}',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,4 +162,63 @@ class CardAnimation {
|
|||
).animate(animationController);
|
||||
animationController.forward();
|
||||
}
|
||||
|
||||
void animateUndo(BuildContext context, CardSwiperDirection direction) {
|
||||
switch (direction) {
|
||||
case CardSwiperDirection.left:
|
||||
return animateUndoHorizontally(context, false);
|
||||
case CardSwiperDirection.right:
|
||||
return animateUndoHorizontally(context, true);
|
||||
case CardSwiperDirection.top:
|
||||
return animateUndoVertically(context, false);
|
||||
case CardSwiperDirection.bottom:
|
||||
return animateUndoVertically(context, true);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void animateUndoHorizontally(BuildContext context, bool isToRight) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
_leftAnimation = Tween<double>(
|
||||
begin: isToRight ? size.width : -size.width,
|
||||
end: 0,
|
||||
).animate(animationController);
|
||||
_topAnimation = Tween<double>(
|
||||
begin: top,
|
||||
end: top + top,
|
||||
).animate(animationController);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: scale,
|
||||
).animate(animationController);
|
||||
_differenceAnimation = Tween<double>(
|
||||
begin: 0,
|
||||
end: difference,
|
||||
).animate(animationController);
|
||||
animationController.forward();
|
||||
}
|
||||
|
||||
void animateUndoVertically(BuildContext context, bool isToBottom) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
_leftAnimation = Tween<double>(
|
||||
begin: left,
|
||||
end: left + left,
|
||||
).animate(animationController);
|
||||
_topAnimation = Tween<double>(
|
||||
begin: isToBottom ? -size.height : size.height,
|
||||
end: 0,
|
||||
).animate(animationController);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: scale,
|
||||
).animate(animationController);
|
||||
_differenceAnimation = Tween<double>(
|
||||
begin: 0,
|
||||
end: difference,
|
||||
).animate(animationController);
|
||||
animationController.forward();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
|
@ -6,6 +7,7 @@ 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.
|
||||
|
|
@ -97,6 +99,13 @@ class CardSwiper extends StatefulWidget {
|
|||
/// 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,
|
||||
|
|
@ -117,6 +126,7 @@ class CardSwiper extends StatefulWidget {
|
|||
this.isVerticalSwipingEnabled = true,
|
||||
this.isLoop = true,
|
||||
this.numberOfCardsDisplayed = 2,
|
||||
this.onUndo,
|
||||
}) : assert(
|
||||
maxAngle >= 0 && maxAngle <= 360,
|
||||
'maxAngle must be between 0 and 360',
|
||||
|
|
@ -135,11 +145,11 @@ class CardSwiper extends StatefulWidget {
|
|||
),
|
||||
assert(
|
||||
numberOfCardsDisplayed >= 1 && numberOfCardsDisplayed <= cardsCount,
|
||||
'you must display at least one card, and no more than the length of cards parameter',
|
||||
'you must display at least one card, and no more than [cardsCount]',
|
||||
),
|
||||
assert(
|
||||
initialIndex >= 0 && initialIndex < cardsCount,
|
||||
'initialIndex must be between 0 and cardsCount',
|
||||
'initialIndex must be between 0 and [cardsCount]',
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
|
|
@ -156,16 +166,18 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
|
|||
CardSwiperDirection _detectedDirection = CardSwiperDirection.none;
|
||||
bool _tappedOnTop = false;
|
||||
|
||||
bool get _canSwipe => _currentIndex != null && !widget.isDisabled;
|
||||
final _undoableIndex = Undoable<int?>(null);
|
||||
final Queue<CardSwiperDirection> _directionHistory = Queue();
|
||||
|
||||
int? _currentIndex;
|
||||
int? get _currentIndex => _undoableIndex.state;
|
||||
int? get _nextIndex => getValidIndexOffset(1);
|
||||
bool get _canSwipe => _currentIndex != null && !widget.isDisabled;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_currentIndex = widget.initialIndex;
|
||||
_undoableIndex.state = widget.initialIndex;
|
||||
|
||||
widget.controller?.addListener(_controllerListener);
|
||||
|
||||
|
|
@ -307,6 +319,8 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
|
|||
return _swipe(CardSwiperDirection.top);
|
||||
case CardSwiperState.swipeBottom:
|
||||
return _swipe(CardSwiperDirection.bottom);
|
||||
case CardSwiperState.undo:
|
||||
return _undo();
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
|
@ -333,17 +347,18 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
|
|||
}
|
||||
|
||||
void _handleCompleteSwipe() {
|
||||
final isLastCard = _currentIndex! == widget.cardsCount - 1;
|
||||
final shouldCancelSwipe =
|
||||
widget.onSwipe?.call(_currentIndex, _nextIndex, _detectedDirection) ==
|
||||
widget.onSwipe?.call(_currentIndex!, _nextIndex, _detectedDirection) ==
|
||||
false;
|
||||
|
||||
if (shouldCancelSwipe) {
|
||||
return;
|
||||
}
|
||||
|
||||
_currentIndex = _nextIndex;
|
||||
_undoableIndex.state = _nextIndex;
|
||||
_directionHistory.add(_detectedDirection);
|
||||
|
||||
final isLastCard = _currentIndex == widget.cardsCount - 1;
|
||||
if (isLastCard) {
|
||||
widget.onEnd?.call();
|
||||
}
|
||||
|
|
@ -387,6 +402,28 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
|
|||
_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;
|
||||
|
|
|
|||
|
|
@ -34,4 +34,10 @@ class CardSwiperController extends ChangeNotifier {
|
|||
state = CardSwiperState.swipeBottom;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Undo the last swipe by changing the status of the controller
|
||||
void undo() {
|
||||
state = CardSwiperState.undo;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
enum CardSwiperState { swipe, swipeLeft, swipeRight, swipeTop, swipeBottom }
|
||||
enum CardSwiperState {
|
||||
swipe,
|
||||
swipeLeft,
|
||||
swipeRight,
|
||||
swipeTop,
|
||||
swipeBottom,
|
||||
undo
|
||||
}
|
||||
|
||||
enum CardSwiperDirection { none, left, right, top, bottom }
|
||||
|
||||
enum SwipeType { none, swipe, back }
|
||||
enum SwipeType { none, swipe, back, undo }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_card_swiper/src/enums.dart';
|
||||
|
||||
extension Range on num {
|
||||
bool isBetween(num from, num to) {
|
||||
return from <= this && this <= to;
|
||||
}
|
||||
}
|
||||
|
||||
extension CardSwiperDirectionExtension on CardSwiperDirection {
|
||||
Axis get axis {
|
||||
switch (this) {
|
||||
case CardSwiperDirection.left:
|
||||
case CardSwiperDirection.right:
|
||||
return Axis.horizontal;
|
||||
case CardSwiperDirection.top:
|
||||
case CardSwiperDirection.bottom:
|
||||
return Axis.vertical;
|
||||
case CardSwiperDirection.none:
|
||||
throw Exception('Direction is none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter_card_swiper/src/enums.dart';
|
||||
|
||||
typedef CardSwiperOnSwipe = bool Function(
|
||||
int? previousIndex,
|
||||
int previousIndex,
|
||||
int? currentIndex,
|
||||
CardSwiperDirection direction,
|
||||
);
|
||||
|
|
@ -9,3 +9,9 @@ typedef CardSwiperOnSwipe = bool Function(
|
|||
typedef CardSwiperOnEnd = void Function();
|
||||
|
||||
typedef CardSwiperOnTapDisabled = void Function();
|
||||
|
||||
typedef CardSwiperOnUndo = bool Function(
|
||||
int? previousIndex,
|
||||
int currentIndex,
|
||||
CardSwiperDirection direction,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
class Undoable<T> {
|
||||
Undoable(this._value, {Undoable? previousValue}) : _previous = previousValue;
|
||||
|
||||
T _value;
|
||||
Undoable? _previous;
|
||||
|
||||
T get state => _value;
|
||||
T? get previousState => _previous?.state;
|
||||
|
||||
set state(T newValue) {
|
||||
_previous = Undoable(_value, previousValue: _previous);
|
||||
_value = newValue;
|
||||
}
|
||||
|
||||
void undo() {
|
||||
if (_previous != null) {
|
||||
_value = _previous!._value;
|
||||
_previous = _previous?._previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ name: flutter_card_swiper
|
|||
description: This is a Tinder-like card swiper package. It allows you to swipe left, right, up, and down and define your own business logic for each direction.
|
||||
homepage: https://github.com/ricardodalarme/flutter_card_swiper
|
||||
issue_tracker: https://github.com/ricardodalarme/flutter_card_swiper/issues
|
||||
version: 4.0.2
|
||||
version: 4.1.0
|
||||
|
||||
environment:
|
||||
sdk: ">=2.12.0 <3.0.0"
|
||||
|
|
|
|||
|
|
@ -33,5 +33,11 @@ void main() {
|
|||
controller.swipeBottom();
|
||||
expect(controller.state, CardSwiperState.swipeBottom);
|
||||
});
|
||||
|
||||
test('undo() changes state to undo', () {
|
||||
final controller = CardSwiperController();
|
||||
controller.undo();
|
||||
expect(controller.state, CardSwiperState.undo);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_card_swiper/src/enums.dart';
|
||||
import 'package:flutter_card_swiper/src/extensions.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('isBetween', () {
|
||||
group('num.isBetween', () {
|
||||
test('should return true when value is within range', () {
|
||||
const value = 5;
|
||||
const from = 1;
|
||||
|
|
@ -43,4 +45,30 @@ void main() {
|
|||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('CardSwiperDirection.axis', () {
|
||||
test('should return horizontal when direction is left', () {
|
||||
final axis = CardSwiperDirection.left.axis;
|
||||
expect(axis, Axis.horizontal);
|
||||
});
|
||||
|
||||
test('should return horizontal when direction is right', () {
|
||||
final axis = CardSwiperDirection.right.axis;
|
||||
expect(axis, Axis.horizontal);
|
||||
});
|
||||
|
||||
test('should return vertical when direction is top', () {
|
||||
final axis = CardSwiperDirection.top.axis;
|
||||
expect(axis, Axis.vertical);
|
||||
});
|
||||
|
||||
test('should return vertical when direction is bottom', () {
|
||||
final axis = CardSwiperDirection.bottom.axis;
|
||||
expect(axis, Axis.vertical);
|
||||
});
|
||||
|
||||
test('should throw exception when direction is none', () {
|
||||
expect(() => CardSwiperDirection.none.axis, throwsException);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:flutter_card_swiper/src/undoable.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('should store and retrieve state', () {
|
||||
final undoable = Undoable<int>(0);
|
||||
expect(undoable.state, equals(0));
|
||||
expect(undoable.previousState, isNull);
|
||||
});
|
||||
|
||||
test('should store previous state when state is changed', () {
|
||||
final undoable = Undoable<int>(0);
|
||||
undoable.state = 1;
|
||||
expect(undoable.state, equals(1));
|
||||
expect(undoable.previousState, equals(0));
|
||||
});
|
||||
|
||||
test('should store previous state when state is changed multiple times', () {
|
||||
final undoable = Undoable<int>(0);
|
||||
undoable.state = 1;
|
||||
undoable.state = 2;
|
||||
expect(undoable.state, equals(2));
|
||||
expect(undoable.previousState, equals(1));
|
||||
});
|
||||
|
||||
test('should return previous state when undo is called', () {
|
||||
final undoable = Undoable<int>(0);
|
||||
undoable.state = 1;
|
||||
undoable.undo();
|
||||
expect(undoable.state, equals(0));
|
||||
expect(undoable.previousState, isNull);
|
||||
});
|
||||
|
||||
test('should return previous state when undo is called multiple times', () {
|
||||
final undoable = Undoable<int>(0);
|
||||
undoable.state = 1;
|
||||
undoable.state = 2;
|
||||
undoable.undo();
|
||||
expect(undoable.state, equals(1));
|
||||
expect(undoable.previousState, equals(0));
|
||||
undoable.undo();
|
||||
expect(undoable.state, equals(0));
|
||||
expect(undoable.previousState, isNull);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue