feat: add undo feature (#1)

This commit is contained in:
ricardodalarme 2023-03-25 11:07:43 -03:00
parent 23c5e9f0d3
commit b5bb8f5c9a
14 changed files with 270 additions and 16 deletions

View File

@ -1,3 +1,7 @@
## [4.1.0]
- Adds option to undo swipes.
## [4.0.2] ## [4.0.2]
- Fixes `onSwipe` callback being called twice. - Fixes `onSwipe` callback being called twice.

View File

@ -113,6 +113,7 @@ class Example extends StatelessWidget {
| isLoop | true | Set to `true` if the stack should loop | false | | isLoop | true | Set to `true` if the stack should loop | false |
| onTapDisabled | - | Function that get triggered when the swiper is disabled | 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 | | 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 | | 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 | | 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 | | numberOfCardsDisplayed | 2 | Number of cards to display at a time | false |

View File

@ -38,15 +38,20 @@ class _ExamplePageState extends State<Example> {
cardsCount: cards.length, cardsCount: cards.length,
numberOfCardsDisplayed: 3, numberOfCardsDisplayed: 3,
onSwipe: _onSwipe, onSwipe: _onSwipe,
onUndo: _onUndo,
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
cardBuilder: (context, index) => cards[index], cardBuilder: (context, index) => cards[index],
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.all(16.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
FloatingActionButton(
onPressed: controller.undo,
child: const Icon(Icons.rotate_left),
),
FloatingActionButton( FloatingActionButton(
onPressed: controller.swipe, onPressed: controller.swipe,
child: const Icon(Icons.rotate_right), child: const Icon(Icons.rotate_right),
@ -77,12 +82,23 @@ class _ExamplePageState extends State<Example> {
} }
bool _onSwipe( bool _onSwipe(
int? previousIndex, int previousIndex,
int? currentIndex, int? currentIndex,
CardSwiperDirection direction, CardSwiperDirection direction,
) { ) {
debugPrint( 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; return true;
} }

View File

@ -162,4 +162,63 @@ class CardAnimation {
).animate(animationController); ).animate(animationController);
animationController.forward(); 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();
}
} }

View File

@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:math'; import 'dart:math';
import 'package:flutter/widgets.dart'; 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/enums.dart';
import 'package:flutter_card_swiper/src/extensions.dart'; import 'package:flutter_card_swiper/src/extensions.dart';
import 'package:flutter_card_swiper/src/typedefs.dart'; import 'package:flutter_card_swiper/src/typedefs.dart';
import 'package:flutter_card_swiper/src/undoable.dart';
class CardSwiper extends StatefulWidget { class CardSwiper extends StatefulWidget {
/// Function that builds each card in the stack. /// 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. /// The default value is 2. Note that you must display at least one card, and no more than the [cardsCount] parameter.
final int numberOfCardsDisplayed; 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({ const CardSwiper({
Key? key, Key? key,
required this.cardBuilder, required this.cardBuilder,
@ -117,6 +126,7 @@ class CardSwiper extends StatefulWidget {
this.isVerticalSwipingEnabled = true, this.isVerticalSwipingEnabled = true,
this.isLoop = true, this.isLoop = true,
this.numberOfCardsDisplayed = 2, this.numberOfCardsDisplayed = 2,
this.onUndo,
}) : assert( }) : assert(
maxAngle >= 0 && maxAngle <= 360, maxAngle >= 0 && maxAngle <= 360,
'maxAngle must be between 0 and 360', 'maxAngle must be between 0 and 360',
@ -135,11 +145,11 @@ class CardSwiper extends StatefulWidget {
), ),
assert( assert(
numberOfCardsDisplayed >= 1 && numberOfCardsDisplayed <= cardsCount, 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( assert(
initialIndex >= 0 && initialIndex < cardsCount, initialIndex >= 0 && initialIndex < cardsCount,
'initialIndex must be between 0 and cardsCount', 'initialIndex must be between 0 and [cardsCount]',
), ),
super(key: key); super(key: key);
@ -156,16 +166,18 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
CardSwiperDirection _detectedDirection = CardSwiperDirection.none; CardSwiperDirection _detectedDirection = CardSwiperDirection.none;
bool _tappedOnTop = false; 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); int? get _nextIndex => getValidIndexOffset(1);
bool get _canSwipe => _currentIndex != null && !widget.isDisabled;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentIndex = widget.initialIndex; _undoableIndex.state = widget.initialIndex;
widget.controller?.addListener(_controllerListener); widget.controller?.addListener(_controllerListener);
@ -307,6 +319,8 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
return _swipe(CardSwiperDirection.top); return _swipe(CardSwiperDirection.top);
case CardSwiperState.swipeBottom: case CardSwiperState.swipeBottom:
return _swipe(CardSwiperDirection.bottom); return _swipe(CardSwiperDirection.bottom);
case CardSwiperState.undo:
return _undo();
default: default:
return; return;
} }
@ -333,17 +347,18 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
} }
void _handleCompleteSwipe() { void _handleCompleteSwipe() {
final isLastCard = _currentIndex! == widget.cardsCount - 1;
final shouldCancelSwipe = final shouldCancelSwipe =
widget.onSwipe?.call(_currentIndex, _nextIndex, _detectedDirection) == widget.onSwipe?.call(_currentIndex!, _nextIndex, _detectedDirection) ==
false; false;
if (shouldCancelSwipe) { if (shouldCancelSwipe) {
return; return;
} }
_currentIndex = _nextIndex; _undoableIndex.state = _nextIndex;
_directionHistory.add(_detectedDirection);
final isLastCard = _currentIndex == widget.cardsCount - 1;
if (isLastCard) { if (isLastCard) {
widget.onEnd?.call(); widget.onEnd?.call();
} }
@ -387,6 +402,28 @@ class _CardSwiperState<T extends Widget> extends State<CardSwiper>
_cardAnimation.animateBack(context); _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() { int numberOfCardsOnScreen() {
if (widget.isLoop) { if (widget.isLoop) {
return widget.numberOfCardsDisplayed; return widget.numberOfCardsDisplayed;

View File

@ -34,4 +34,10 @@ class CardSwiperController extends ChangeNotifier {
state = CardSwiperState.swipeBottom; state = CardSwiperState.swipeBottom;
notifyListeners(); notifyListeners();
} }
// Undo the last swipe by changing the status of the controller
void undo() {
state = CardSwiperState.undo;
notifyListeners();
}
} }

View File

@ -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 CardSwiperDirection { none, left, right, top, bottom }
enum SwipeType { none, swipe, back } enum SwipeType { none, swipe, back, undo }

View File

@ -1,5 +1,23 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_card_swiper/src/enums.dart';
extension Range on num { extension Range on num {
bool isBetween(num from, num to) { bool isBetween(num from, num to) {
return from <= this && this <= 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');
}
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter_card_swiper/src/enums.dart'; import 'package:flutter_card_swiper/src/enums.dart';
typedef CardSwiperOnSwipe = bool Function( typedef CardSwiperOnSwipe = bool Function(
int? previousIndex, int previousIndex,
int? currentIndex, int? currentIndex,
CardSwiperDirection direction, CardSwiperDirection direction,
); );
@ -9,3 +9,9 @@ typedef CardSwiperOnSwipe = bool Function(
typedef CardSwiperOnEnd = void Function(); typedef CardSwiperOnEnd = void Function();
typedef CardSwiperOnTapDisabled = void Function(); typedef CardSwiperOnTapDisabled = void Function();
typedef CardSwiperOnUndo = bool Function(
int? previousIndex,
int currentIndex,
CardSwiperDirection direction,
);

21
lib/src/undoable.dart Normal file
View File

@ -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;
}
}
}

View File

@ -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. 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 homepage: https://github.com/ricardodalarme/flutter_card_swiper
issue_tracker: https://github.com/ricardodalarme/flutter_card_swiper/issues issue_tracker: https://github.com/ricardodalarme/flutter_card_swiper/issues
version: 4.0.2 version: 4.1.0
environment: environment:
sdk: ">=2.12.0 <3.0.0" sdk: ">=2.12.0 <3.0.0"

View File

@ -33,5 +33,11 @@ void main() {
controller.swipeBottom(); controller.swipeBottom();
expect(controller.state, CardSwiperState.swipeBottom); expect(controller.state, CardSwiperState.swipeBottom);
}); });
test('undo() changes state to undo', () {
final controller = CardSwiperController();
controller.undo();
expect(controller.state, CardSwiperState.undo);
});
}); });
} }

View File

@ -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_card_swiper/src/extensions.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('isBetween', () { group('num.isBetween', () {
test('should return true when value is within range', () { test('should return true when value is within range', () {
const value = 5; const value = 5;
const from = 1; const from = 1;
@ -43,4 +45,30 @@ void main() {
expect(result, isFalse); 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);
});
});
} }

45
test/undoable_test.dart Normal file
View File

@ -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);
});
}