Created
April 26, 2024 14:25
-
-
Save followthemoney1/469d2c53ae5bdc7dc6c471042360e909 to your computer and use it in GitHub Desktop.
card swiper created with custom transform
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
void main() { | |
runApp(const MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
const MyApp({super.key}); | |
@override | |
Widget build(BuildContext context) { | |
final controller = HomeCardsStackController() | |
..cards = [ | |
HomeCard(index: 0), | |
HomeCard(index: 1), | |
HomeCard(index: 3), | |
HomeCard(index: 4), | |
HomeCard(index: 5), | |
]; | |
return MaterialApp( | |
debugShowCheckedModeBanner: false, | |
home: Scaffold( | |
body: Center( | |
child: Container(height:220,child:HomeCardsStack(cardsStackController: controller),), | |
), | |
), | |
); | |
} | |
} | |
enum SwipeDirection { | |
none, | |
left, | |
right; | |
const SwipeDirection(); | |
static SwipeDirection fromPrimaryVelocity(double primaryVelocity) { | |
if (primaryVelocity < 0) return SwipeDirection.left; | |
if (primaryVelocity > 0) return SwipeDirection.right; | |
return SwipeDirection.none; | |
} | |
bool get isRight => this == SwipeDirection.right; | |
bool get isLeft => this == SwipeDirection.left; | |
} | |
class HomeCardsStackController extends ChangeNotifier { | |
List<HomeCard> cards = <HomeCard>[]; | |
SwipeDirection direction = SwipeDirection.left; | |
int get length => cards.length; | |
void next() { | |
direction = SwipeDirection.right; | |
notifyListeners(); | |
} | |
void prev() { | |
direction = SwipeDirection.left; | |
notifyListeners(); | |
} | |
void clearDirection() { | |
direction = SwipeDirection.none; | |
} | |
void notify() { | |
notifyListeners(); | |
} | |
} | |
class HomeCardsStack extends StatefulWidget { | |
final HomeCardsStackController cardsStackController; | |
final Function? onCardChanged; | |
const HomeCardsStack({ | |
required this.cardsStackController, | |
this.onCardChanged, | |
}); | |
@override | |
_HomeCardsStackState createState() => _HomeCardsStackState(); | |
} | |
class _HomeCardsStackState extends State<HomeCardsStack> | |
with TickerProviderStateMixin { | |
static const backCardsScaleFactor = 0.965; | |
static const backCardsVerticalOffset = 0.05; | |
static const cardMoveOffset = 1000.0; | |
static const maximumVisibleCardIndex = 2; | |
static const animationDurationInMilliseconds = 350; | |
late List<HomeCard> cards; | |
late int currentIndex; | |
late AnimationController controller; | |
late CurvedAnimation curvedAnimation; | |
late Animation<Offset> _translationAnim; | |
late Animation<Offset> _moveAnim; | |
late Animation<double> _scaleAnim; | |
SwipeDirection direction = SwipeDirection.none; | |
static double get scaleDiffBetweenCards => 1 - backCardsScaleFactor; | |
int get lastVisibleCardIndex => maximumVisibleCardIndex >= cards.length | |
? cards.length - 1 | |
: maximumVisibleCardIndex; | |
/// The _translationAnim moves card to left and then to the right(below the stack) and otherwise | |
/// The function returns whether the function has completed half of the flow | |
/// and now moves back to the stack | |
bool get isReverseAnimation => | |
_translationAnim.value.dx.abs() > (cardMoveOffset / 2); | |
@override | |
void initState() { | |
super.initState(); | |
currentIndex = 0; | |
cards = [...widget.cardsStackController.cards]; | |
widget.cardsStackController.addListener(() { | |
if (widget.cardsStackController.direction != SwipeDirection.none) { | |
direction = widget.cardsStackController.direction; | |
_animate(); | |
widget.cardsStackController.clearDirection(); | |
} | |
}); | |
_initializeControllerAndAnimation(); | |
} | |
void _initializeControllerAndAnimation() { | |
controller = AnimationController( | |
vsync: this, | |
duration: | |
const Duration(milliseconds: animationDurationInMilliseconds)); | |
curvedAnimation = | |
CurvedAnimation(parent: controller, curve: Curves.easeInOut); | |
_translationAnim = | |
Tween(begin: const Offset(0, 0), end: const Offset(cardMoveOffset, 0)) | |
.animate(curvedAnimation) | |
..addListener(() { | |
setState(() { | |
/// Move the card to the back and rebuild the UI | |
/// So the card positions would be in the bottom | |
if (isReverseAnimation && cards.first.index == currentIndex) { | |
_moveFirstCardToBack(); | |
} | |
}); | |
}); | |
_scaleAnim = Tween<double>(begin: backCardsScaleFactor, end: 1) | |
.animate(curvedAnimation); | |
_moveAnim = Tween( | |
begin: const Offset(0, backCardsVerticalOffset), | |
end: const Offset(0, 0)) | |
.animate(curvedAnimation); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Stack( | |
clipBehavior: Clip.none, | |
children: cards.reversed.map(_buildCard).toList(), | |
); | |
} | |
Widget _buildCard(HomeCard card) { | |
return GestureDetector( | |
onHorizontalDragEnd: _horizontalDragEnd, | |
child: Transform.translate( | |
offset: _getFlickTransformOffset(card), | |
child: FractionalTranslation( | |
translation: _getStackedCardOffset(card), | |
child: Transform.scale( | |
scale: _getStackedCardScale(card), | |
child: Center( | |
child: Opacity( | |
opacity: | |
1, //(card.index == currentIndex || _isNextCard(card)) ? 0.5 : 1, | |
child: card, | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
Offset _getStackedCardOffset(HomeCard card) { | |
final isStackBiggerThanMaximum = cards.length > maximumVisibleCardIndex + 1; | |
if (_isNextCard(card) && isStackBiggerThanMaximum) return _moveAnim.value; | |
double verticalOffset = 0; | |
final posAfterFront = _getCardsPositionRelativelyToTheFront(card); | |
/// If animating, only visible two cards should move | |
/// Because otherwise, it would show the next third card, which was previously invisible | |
if (controller.isAnimating && posAfterFront <= lastVisibleCardIndex) { | |
final positionChangedAfterReverse = | |
isReverseAnimation && !isStackBiggerThanMaximum ? 1 : 0; | |
final initialPosition = backCardsVerticalOffset * | |
(posAfterFront + positionChangedAfterReverse); | |
final moveAnimReverse = backCardsVerticalOffset - _moveAnim.value.dy; | |
verticalOffset = initialPosition - moveAnimReverse; | |
} | |
/// If the animation has finished, show three cards | |
if (!controller.isAnimating && posAfterFront <= lastVisibleCardIndex) { | |
verticalOffset = backCardsVerticalOffset * posAfterFront; | |
} | |
if (controller.isAnimating && | |
isReverseAnimation && | |
posAfterFront == cards.length - 1) { | |
verticalOffset = backCardsVerticalOffset * lastVisibleCardIndex; | |
} | |
return Offset(0, verticalOffset); | |
} | |
double _getStackedCardScale(HomeCard card) { | |
final posAfterFront = _getCardsPositionRelativelyToTheFront(card); | |
if (card.index == currentIndex) { | |
/// If the swiped card is moving into the reverse direction | |
/// Make the scale as the last visibile cards to fit it into that position. | |
return isReverseAnimation | |
? 1 - (scaleDiffBetweenCards * lastVisibleCardIndex) | |
: 1; | |
} else if (_isNextCard(card)) { | |
return _scaleAnim.value; | |
} else { | |
final positionInTheStack = posAfterFront >= lastVisibleCardIndex | |
? lastVisibleCardIndex | |
: posAfterFront; | |
if (controller.isAnimating && posAfterFront <= lastVisibleCardIndex) { | |
return _scaleAnim.value - scaleDiffBetweenCards; | |
} | |
if (!controller.isAnimating && posAfterFront <= lastVisibleCardIndex) { | |
return 1 - (scaleDiffBetweenCards * positionInTheStack); | |
} | |
return 0; | |
} | |
} | |
Offset _getFlickTransformOffset(HomeCard card) { | |
if (card.index == currentIndex) { | |
final isRight = direction == SwipeDirection.right; | |
const middle = cardMoveOffset / 2; | |
final diff = _translationAnim.value.dx.abs() - middle; | |
// If the card is still moving to the initial swipe direction | |
if (diff <= 0) | |
return isRight ? _translationAnim.value : -_translationAnim.value; | |
// Now the swiped card is moving to the back positions | |
final dx = isRight ? middle - diff : (diff - middle); | |
return Offset(dx, 0); | |
} | |
return const Offset(0, 0); | |
} | |
void _horizontalDragEnd(DragEndDetails details) { | |
final primaryVelocity = details.primaryVelocity ?? 0; | |
if (primaryVelocity != 0) { | |
direction = SwipeDirection.fromPrimaryVelocity(primaryVelocity); | |
_animate(); | |
widget.cardsStackController.notify(); | |
} | |
} | |
void _animate() { | |
if (cards.length < 2) return; | |
controller.forward().whenComplete(() { | |
setState(() { | |
controller.reset(); | |
currentIndex = cards[0].index; | |
}); | |
}); | |
} | |
/// Position of the current card after the front card | |
/// where frontCard = 0 | |
int _getCardsPositionRelativelyToTheFront(HomeCard card) { | |
return cards.toList().indexWhere((c) => c.index == card.index); | |
} | |
bool _isNextCard(HomeCard card) { | |
return card.index == ((currentIndex + 1) % cards.length); | |
} | |
void _moveFirstCardToBack() { | |
final HomeCard removedCard = cards.removeAt(0); | |
print('>>> Card moved back'); | |
setState(() { | |
cards.add(removedCard); | |
}); | |
} | |
} | |
class HomeCard extends StatelessWidget { | |
final int index; | |
const HomeCard({ | |
required this.index, | |
super.key, | |
}); | |
@override | |
Widget build(BuildContext context) { | |
return Card( | |
color: Colors.amber, | |
child: Column( | |
children: [ | |
SizedBox(height: 60), | |
Text('Card Example'), | |
SizedBox(height: 60), | |
], | |
), | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screen.Recording.2024-04-26.at.15.23.53.mov