From b5bb8f5c9ae98c6e3d83d83974b1b797e485bb30 Mon Sep 17 00:00:00 2001 From: ricardodalarme Date: Sat, 25 Mar 2023 11:07:43 -0300 Subject: [PATCH] feat: add undo feature (#1) --- CHANGELOG.md | 4 ++ README.md | 1 + example/lib/main.dart | 22 ++++++++-- lib/src/card_animation.dart | 59 +++++++++++++++++++++++++++ lib/src/card_swiper.dart | 53 ++++++++++++++++++++---- lib/src/card_swiper_controller.dart | 6 +++ lib/src/enums.dart | 11 ++++- lib/src/extensions.dart | 18 ++++++++ lib/src/typedefs.dart | 8 +++- lib/src/undoable.dart | 21 ++++++++++ pubspec.yaml | 2 +- test/card_swiper_controller_test.dart | 6 +++ test/extensions_test.dart | 30 +++++++++++++- test/undoable_test.dart | 45 ++++++++++++++++++++ 14 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 lib/src/undoable.dart create mode 100644 test/undoable_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 215889e..0eb1811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [4.1.0] + +- Adds option to undo swipes. + ## [4.0.2] - Fixes `onSwipe` callback being called twice. diff --git a/README.md b/README.md index 5081f99..5f05cc0 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/example/lib/main.dart b/example/lib/main.dart index 361a97a..8458741 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -38,15 +38,20 @@ class _ExamplePageState extends State { 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 { } 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; } diff --git a/lib/src/card_animation.dart b/lib/src/card_animation.dart index 6ed39b0..0ed848c 100644 --- a/lib/src/card_animation.dart +++ b/lib/src/card_animation.dart @@ -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( + begin: isToRight ? size.width : -size.width, + end: 0, + ).animate(animationController); + _topAnimation = Tween( + begin: top, + end: top + top, + ).animate(animationController); + _scaleAnimation = Tween( + begin: 1.0, + end: scale, + ).animate(animationController); + _differenceAnimation = Tween( + begin: 0, + end: difference, + ).animate(animationController); + animationController.forward(); + } + + void animateUndoVertically(BuildContext context, bool isToBottom) { + final size = MediaQuery.of(context).size; + + _leftAnimation = Tween( + begin: left, + end: left + left, + ).animate(animationController); + _topAnimation = Tween( + begin: isToBottom ? -size.height : size.height, + end: 0, + ).animate(animationController); + _scaleAnimation = Tween( + begin: 1.0, + end: scale, + ).animate(animationController); + _differenceAnimation = Tween( + begin: 0, + end: difference, + ).animate(animationController); + animationController.forward(); + } } diff --git a/lib/src/card_swiper.dart b/lib/src/card_swiper.dart index 97a6f70..4df6b6b 100644 --- a/lib/src/card_swiper.dart +++ b/lib/src/card_swiper.dart @@ -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 extends State CardSwiperDirection _detectedDirection = CardSwiperDirection.none; bool _tappedOnTop = false; - bool get _canSwipe => _currentIndex != null && !widget.isDisabled; + final _undoableIndex = Undoable(null); + final Queue _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 extends State return _swipe(CardSwiperDirection.top); case CardSwiperState.swipeBottom: return _swipe(CardSwiperDirection.bottom); + case CardSwiperState.undo: + return _undo(); default: return; } @@ -333,17 +347,18 @@ class _CardSwiperState extends State } 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 extends State _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; diff --git a/lib/src/card_swiper_controller.dart b/lib/src/card_swiper_controller.dart index fc0c1c6..0f6ae39 100644 --- a/lib/src/card_swiper_controller.dart +++ b/lib/src/card_swiper_controller.dart @@ -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(); + } } diff --git a/lib/src/enums.dart b/lib/src/enums.dart index 8f0b86c..80c89e0 100644 --- a/lib/src/enums.dart +++ b/lib/src/enums.dart @@ -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 } diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 7556624..4d14410 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -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'); + } + } +} diff --git a/lib/src/typedefs.dart b/lib/src/typedefs.dart index a2390ab..311d1fe 100644 --- a/lib/src/typedefs.dart +++ b/lib/src/typedefs.dart @@ -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, +); diff --git a/lib/src/undoable.dart b/lib/src/undoable.dart new file mode 100644 index 0000000..f79f428 --- /dev/null +++ b/lib/src/undoable.dart @@ -0,0 +1,21 @@ +class Undoable { + 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; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 338ef18..4d92143 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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" diff --git a/test/card_swiper_controller_test.dart b/test/card_swiper_controller_test.dart index e7c1def..f48cfda 100644 --- a/test/card_swiper_controller_test.dart +++ b/test/card_swiper_controller_test.dart @@ -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); + }); }); } diff --git a/test/extensions_test.dart b/test/extensions_test.dart index 729479f..2c88b57 100644 --- a/test/extensions_test.dart +++ b/test/extensions_test.dart @@ -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); + }); + }); } diff --git a/test/undoable_test.dart b/test/undoable_test.dart new file mode 100644 index 0000000..3524a12 --- /dev/null +++ b/test/undoable_test.dart @@ -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(0); + expect(undoable.state, equals(0)); + expect(undoable.previousState, isNull); + }); + + test('should store previous state when state is changed', () { + final undoable = Undoable(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(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(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(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); + }); +}