Skip to content

Instantly share code, notes, and snippets.

@followthemoney1
Created April 26, 2024 14:25
Show Gist options
  • Save followthemoney1/469d2c53ae5bdc7dc6c471042360e909 to your computer and use it in GitHub Desktop.
Save followthemoney1/469d2c53ae5bdc7dc6c471042360e909 to your computer and use it in GitHub Desktop.
card swiper created with custom transform
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),
],
),
);
}
}
@followthemoney1
Copy link
Author

Screen.Recording.2024-04-26.at.15.23.53.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment