Created
June 9, 2022 23:37
-
-
Save Roaa94/5734add617929fd7448740d7ce16ae0c to your computer and use it in GitHub Desktop.
Flutter Cool Card Swiper
This file contains hidden or 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 'dart:math' as math; | |
import 'package:flutter/material.dart'; | |
void main() => runApp(MyApp()); | |
class MyApp extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
title: 'Flutter Starter', | |
debugShowCheckedModeBanner: false, | |
theme: ThemeData( | |
primarySwatch: Colors.teal, | |
), | |
home: const HomePage(), | |
); | |
} | |
} | |
class HomePage extends StatelessWidget { | |
const HomePage({Key? key}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
backgroundColor: Colors.black, | |
body: SafeArea( | |
child: Padding( | |
padding: const EdgeInsets.all(20), | |
child: CoolSwiper( | |
children: List.generate( | |
Data.colors.length, | |
(index) => CardContent(color: Data.colors[index]), | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
class CoolSwiper extends StatefulWidget { | |
final List<Widget> children; | |
final double initAnimationOffset; | |
final double cardHeight; | |
const CoolSwiper({ | |
Key? key, | |
required this.children, | |
this.initAnimationOffset = Constants.initAnimationOffset, | |
this.cardHeight = Constants.cardHeight, | |
}) : super(key: key); | |
@override | |
State<CoolSwiper> createState() => _CoolSwiperState(); | |
} | |
class _CoolSwiperState extends State<CoolSwiper> | |
with SingleTickerProviderStateMixin { | |
late final AnimationController backgroundCardsAnimationController; | |
late final List<Widget> stackChildren; | |
final ValueNotifier<bool> _backgroundCardsAreInFrontNotifier = | |
ValueNotifier<bool>(false); | |
bool fireBackgroundCardsAnimation = false; | |
late final List<SwiperCard> _cards; | |
List<Widget> get _stackChildren => List.generate( | |
_cards.length, | |
(i) { | |
return CoolSwiperCard( | |
key: ValueKey('__animated_card_${i}__'), | |
card: _cards[i], | |
height: widget.cardHeight, | |
initAnimationOffset: widget.initAnimationOffset, | |
onAnimationTrigger: _onAnimationTrigger, | |
onVerticalDragEnd: () {}, | |
); | |
}, | |
); | |
void _onAnimationTrigger() async { | |
setState(() { | |
fireBackgroundCardsAnimation = true; | |
}); | |
backgroundCardsAnimationController.forward(); | |
Future.delayed(Constants.backgroundCardsAnimationDuration).then( | |
(_) { | |
_backgroundCardsAreInFrontNotifier.value = true; | |
}, | |
); | |
Future.delayed(Constants.swipeAnimationDuration).then( | |
(_) { | |
_backgroundCardsAreInFrontNotifier.value = false; | |
backgroundCardsAnimationController.reset(); | |
_swapLast(); | |
}, | |
); | |
} | |
void _swapLast() { | |
Widget last = stackChildren[stackChildren.length - 1]; | |
setState(() { | |
stackChildren.removeLast(); | |
stackChildren.insert(0, last); | |
}); | |
} | |
@override | |
void initState() { | |
super.initState(); | |
_cards = SwiperCard.listFromWidgets(widget.children); | |
stackChildren = _stackChildren; | |
backgroundCardsAnimationController = AnimationController( | |
vsync: this, | |
duration: Constants.backgroundCardsAnimationDuration, | |
); | |
} | |
@override | |
void dispose() { | |
backgroundCardsAnimationController.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Stack( | |
children: [ | |
ValueListenableBuilder( | |
valueListenable: _backgroundCardsAreInFrontNotifier, | |
builder: (c, bool backgroundCardsAreInFront, _) => | |
backgroundCardsAreInFront | |
? Positioned(child: Container()) | |
: _buildBackgroundCardsStack(), | |
), | |
_buildFrontCard(), | |
ValueListenableBuilder( | |
valueListenable: _backgroundCardsAreInFrontNotifier, | |
builder: (c, bool backgroundCardsAreInFront, _) => | |
backgroundCardsAreInFront | |
? _buildBackgroundCardsStack() | |
: Positioned(child: Container()), | |
), | |
], | |
); | |
} | |
Widget _buildBackgroundCardsStack() { | |
return Stack( | |
children: List.generate( | |
_cards.length - 1, | |
(i) => _buildStackChild(i), | |
), | |
); | |
} | |
Widget _buildFrontCard() { | |
return _buildStackChild(_cards.length - 1); | |
} | |
Widget _buildStackChild(int i) { | |
return Positioned( | |
bottom: 0, | |
left: 0, | |
right: 0, | |
child: IgnorePointer( | |
ignoring: i != stackChildren.length - 1, | |
child: CoolSwiperCardWrapper( | |
animationController: backgroundCardsAnimationController, | |
initialScale: _cards[i].scale, | |
initialYOffset: _cards[i].yOffset, | |
child: stackChildren[i], | |
), | |
), | |
); | |
} | |
} | |
/// This is the widget responsible for user drag & release animations | |
/// | |
/// It also sends drag information to root stack widget | |
class CoolSwiperCard extends StatefulWidget { | |
final SwiperCard card; | |
final Function onAnimationTrigger; | |
final Function onVerticalDragEnd; | |
final double height; | |
final double initAnimationOffset; | |
const CoolSwiperCard({ | |
Key? key, | |
required this.card, | |
required this.onAnimationTrigger, | |
required this.onVerticalDragEnd, | |
required this.height, | |
required this.initAnimationOffset, | |
}) : super(key: key); | |
@override | |
State<CoolSwiperCard> createState() => _CoolSwiperCardState(); | |
} | |
class _CoolSwiperCardState extends State<CoolSwiperCard> | |
with SingleTickerProviderStateMixin { | |
late final AnimationController animationController; | |
late final Animation<double> rotationAnimation; | |
late final Animation<double> slideUpAnimation; | |
late final Animation<double> slideDownAnimation; | |
late final Animation<double> scaleAnimation; | |
Tween<double> rotationAnimationTween = Tween<double>(begin: 0, end: -360); | |
Tween<double> slideDownAnimationTween = Tween<double>(begin: 0, end: 0); | |
double yDragOffset = 0; | |
double dragStartAngle = 0; | |
Alignment dragStartRotationAlignment = Alignment.centerRight; | |
Duration dragDuration = const Duration(milliseconds: 0); | |
/// When the drag starts, the card rotates a small angle | |
/// with an alignment based on the touch/click location of the user | |
/// | |
/// And the main flying rotation tween gets its end value based on the | |
/// touch/click location as well to determine whether the flying flip will | |
/// happen with a negative or positive angle | |
void _onVerticalDragStart(DragStartDetails details) { | |
double screenWidth = MediaQuery.of(context).size.width; | |
final xPosition = details.globalPosition.dx; | |
final yPosition = details.localPosition.dy; | |
final angleMultiplier = xPosition > screenWidth / 2 ? -1 : 1; | |
rotationAnimationTween.end = | |
Constants.rotationAnimationAngleDeg * angleMultiplier; | |
// Update values of the small angle drag start rotation animation | |
setState(() { | |
dragStartRotationAlignment = getDragStartPositionAlignment( | |
xPosition, | |
yPosition, | |
screenWidth, | |
widget.height, | |
); | |
dragStartAngle = Constants.dragStartEndAngle * angleMultiplier; | |
// If the drag duration is larger than zero, rest to zero | |
// to allow the card to move with user finger/mouse smoothly | |
if (dragDuration > Duration.zero) { | |
dragDuration = Duration.zero; | |
} | |
}); | |
} | |
/// When the drag ends, first a check is made to ensure the card travelled some | |
/// offset distance upwards, | |
/// if it didn't, the cards returns to place | |
/// if it did, the animation is triggered by | |
/// - calling a callback to the parent widget | |
/// - changing the end value of the slide down animation tween | |
/// based on how much distance the card travelled | |
/// - calling forward() on the animation controller | |
/// | |
/// After the animation finishes, a callback to the parent widget is | |
/// called to let it know that it can swap the background cards and brings | |
/// them forward to reset the indices and allow for the next card to be dragged & animated | |
void _onVerticalDragEnd(DragEndDetails details) { | |
if ((yDragOffset * -1) > widget.initAnimationOffset) { | |
widget.onAnimationTrigger(); | |
slideDownAnimationTween.end = Constants.throwSlideYDistance + | |
yDragOffset.abs() - | |
(widget.card.totalCount - 1) * Constants.yOffset; | |
animationController.forward().then((value) { | |
widget.onVerticalDragEnd(); | |
setState(() { | |
dragStartAngle = 0; | |
}); | |
}); | |
} else { | |
setState(() { | |
// Set a non-zero drag rotation to allow the card to reset to original | |
// position smoothly rather than snapping back into place | |
dragDuration = const Duration(milliseconds: 200); | |
yDragOffset = 0; | |
dragStartAngle = 0; | |
}); | |
} | |
} | |
/// This moves the card with user touch/click & hold | |
void _onVerticalDragUpdate(DragUpdateDetails details) { | |
setState(() { | |
yDragOffset += details.delta.dy; | |
}); | |
} | |
@override | |
void initState() { | |
super.initState(); | |
animationController = AnimationController( | |
vsync: this, | |
duration: Constants.swipeAnimationDuration, | |
); | |
rotationAnimation = rotationAnimationTween.animate(CurvedAnimation( | |
parent: animationController, | |
curve: Curves.easeInOut, | |
)); | |
scaleAnimation = Tween<double>( | |
begin: 1, | |
end: 1 - ((widget.card.totalCount - 1) * Constants.scaleFraction), | |
).animate(animationController); | |
// Staggered animation is used here to allow | |
// sequencing the slide up & slide down animations | |
slideUpAnimation = Tween<double>( | |
begin: 0, | |
end: -Constants.throwSlideYDistance, | |
).animate(CurvedAnimation( | |
parent: animationController, | |
curve: const Interval(0, 0.5, curve: Curves.linear), | |
)); | |
slideDownAnimation = slideDownAnimationTween.animate(CurvedAnimation( | |
parent: animationController, | |
curve: const Interval(0.5, 1, curve: Curves.linear), | |
)); | |
} | |
@override | |
void dispose() { | |
animationController.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return GestureDetector( | |
onVerticalDragStart: _onVerticalDragStart, | |
onVerticalDragUpdate: _onVerticalDragUpdate, | |
onVerticalDragEnd: _onVerticalDragEnd, | |
child: TweenAnimationBuilder<double>( | |
tween: Tween<double>(begin: 0, end: yDragOffset), | |
duration: dragDuration, | |
curve: Curves.easeOut, | |
// This TweenAnimationBuilder widget is responsible for the user | |
// touch/click & hold dragging | |
// Or the DRAG UPDATE ANIMATION | |
builder: (c, double value, child) => Transform.translate( | |
offset: Offset(0, value), | |
child: child, | |
), | |
child: AnimatedBuilder( | |
animation: animationController, | |
// This widgets is responsible for the small angle rotation | |
// triggered on user touch/click & hold | |
// Or the DRAG START ANIMATION | |
child: AnimatedRotation( | |
turns: dragStartAngle, | |
alignment: dragStartRotationAlignment, | |
duration: const Duration(milliseconds: 200), | |
child: widget.card.child, | |
), | |
builder: (c, child) { | |
// This widgets inside the builder method of the AnimatedBuilder | |
// widget are responsible for the: | |
// slide-up => rotation => slide-down animations | |
// Or the DRAG END ANIMATION | |
return Transform.translate( | |
// slide up some distance beyond drag location | |
offset: Offset(0, slideUpAnimation.value), | |
child: Transform.translate( | |
// slide down into place | |
offset: Offset(0, slideDownAnimation.value), | |
child: Transform.rotate( | |
// rotate | |
angle: rotationAnimation.value * (math.pi / 180), | |
alignment: Alignment.center, | |
child: Transform.scale( | |
// Scale down to scale of the smallest card in stack | |
scale: scaleAnimation.value, | |
child: child, | |
), | |
), | |
), | |
); | |
}, | |
), | |
), | |
); | |
} | |
} | |
/// This widget is responsible for scaling up & sliding down | |
/// the background cards of the the card being dragged to give the | |
/// illusion that they replaced it | |
/// | |
/// the animationController is passed to it from the parent widget | |
/// because the parent widget calls the forward() method on it | |
/// when it knows that the rotation main animation has been triggerred | |
class CoolSwiperCardWrapper extends StatefulWidget { | |
final Widget child; | |
final double initialScale; | |
final double initialYOffset; | |
final bool fire; | |
final AnimationController animationController; | |
const CoolSwiperCardWrapper({ | |
Key? key, | |
required this.child, | |
this.initialScale = 1, | |
this.initialYOffset = 0, | |
this.fire = false, | |
required this.animationController, | |
}) : super(key: key); | |
@override | |
State<CoolSwiperCardWrapper> createState() => _CoolSwiperCardWrapperState(); | |
} | |
class _CoolSwiperCardWrapperState extends State<CoolSwiperCardWrapper> | |
with SingleTickerProviderStateMixin { | |
late final AnimationController animationController; | |
late final Animation<double> yOffsetAnimation; | |
late final Animation<double> scaleAnimation; | |
@override | |
void initState() { | |
super.initState(); | |
animationController = widget.animationController; | |
yOffsetAnimation = Tween<double>( | |
begin: widget.initialYOffset, | |
end: widget.initialYOffset - Constants.yOffset, | |
).animate( | |
CurvedAnimation( | |
parent: animationController, | |
curve: Curves.easeOutBack, | |
), | |
); | |
scaleAnimation = Tween<double>( | |
begin: widget.initialScale, | |
end: widget.initialScale + Constants.scaleFraction, | |
).animate( | |
CurvedAnimation( | |
parent: animationController, | |
curve: Curves.easeOutBack, | |
), | |
); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: animationController, | |
builder: (c, child) => Transform.translate( | |
offset: Offset(0, -yOffsetAnimation.value), | |
child: Transform.scale( | |
scale: scaleAnimation.value, | |
child: child, | |
), | |
), | |
child: widget.child, | |
); | |
} | |
} | |
class CardContent extends StatelessWidget { | |
final Color color; | |
const CardContent({ | |
Key? key, | |
required this.color, | |
}) : super(key: key); | |
@override | |
Widget build(BuildContext context) { | |
return Container( | |
height: Constants.cardHeight, | |
padding: const EdgeInsets.all(40), | |
decoration: BoxDecoration( | |
color: color, | |
borderRadius: BorderRadius.circular(18), | |
), | |
child: Align( | |
alignment: Alignment.bottomLeft, | |
child: Row( | |
crossAxisAlignment: CrossAxisAlignment.end, | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
Container( | |
height: 40, | |
width: 40, | |
decoration: BoxDecoration( | |
color: Colors.black.withOpacity(0.2), | |
shape: BoxShape.circle, | |
), | |
), | |
const SizedBox(width: 15), | |
Column( | |
mainAxisAlignment: MainAxisAlignment.end, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Container( | |
height: 15, | |
width: 150, | |
decoration: BoxDecoration( | |
color: Colors.black.withOpacity(0.2), | |
borderRadius: BorderRadius.circular(10), | |
), | |
), | |
const SizedBox(height: 10), | |
Container( | |
height: 15, | |
width: 100, | |
decoration: BoxDecoration( | |
color: Colors.black.withOpacity(0.2), | |
borderRadius: BorderRadius.circular(10), | |
), | |
), | |
], | |
) | |
], | |
), | |
), | |
); | |
} | |
} | |
Alignment getDragStartPositionAlignment( | |
double xPosition, | |
double yPosition, | |
double width, | |
double height, | |
) { | |
if (xPosition > width / 2) { | |
return yPosition > height / 2 ? Alignment.bottomRight : Alignment.topRight; | |
} else { | |
return yPosition > height / 2 ? Alignment.bottomLeft : Alignment.topLeft; | |
} | |
} | |
class SwiperCard { | |
final int order; | |
final double scale; | |
final double yOffset; | |
final Widget child; | |
final int totalCount; | |
const SwiperCard({ | |
required this.order, | |
required this.child, | |
required this.totalCount, | |
}) : scale = 1 - (order * Constants.scaleFraction), | |
yOffset = order * Constants.yOffset; | |
static List<SwiperCard> listFromWidgets(List<Widget> children) { | |
return List.generate( | |
children.length, | |
(i) => SwiperCard( | |
order: i, | |
child: children[i], | |
totalCount: children.length, | |
), | |
).reversed.toList(); | |
} | |
} | |
class Constants { | |
static const double initAnimationOffset = 100; | |
static const double cardHeight = 220; | |
static const double dragStartEndAngle = 0.01; | |
static const double rotationAnimationAngleDeg = 360; | |
static const double scaleFraction = 0.05; | |
static const double yOffset = 13; | |
static const double throwSlideYDistance = 200; | |
static const Duration backgroundCardsAnimationDuration = Duration(milliseconds: 300); | |
static const Duration swipeAnimationDuration = Duration(milliseconds: 500); | |
} | |
class Data { | |
static List<Color> colors = [ | |
Colors.red.shade300, | |
Colors.yellow.shade200, | |
Colors.blue.shade300, | |
Colors.white, | |
]; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment