Some days back, I implemented the Flutterwave's animated card deck from Flutterwave's landing page.
I shared it on Twitter and thought it'd be a nice idea to write an article on how I implemented it.
So, here it is π
We'll be using 3 main widgets:
- Stack.
- AnimatedBuilder.
- Transform.
Set up the flutter project
Create a new Flutter project.
flutter create flutterwave_card_deck
Open the project using your preferred IDE.
Creating the Card
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
fontFamily: 'Montserrat',
),
home: Home(),
);
}
}
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: ATMCardUI(),
),
);
}
}
class ATMCardUI extends StatelessWidget {
String get getCardPan {
return ("1234567890181234").replaceAllMapped(
RegExp(r".{4}"), (match) => "${match.group(0)} ");
}
Widget build(BuildContext context) {
return Container(
width: 450,
height: 280,
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.masks_rounded,
color: Colors.white,
size: 60,
),
Transform.translate(
offset: Offset(0, -2),
child: Text(
"Flutter Card",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
Spacer(),
Text(
getCardPan,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 25,
),
),
Spacer(),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"VALID\nTRU",
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
SizedBox(width: 10),
Text(
"10/21",
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w600,
),
)
],
),
SizedBox(height: 10),
Text(
"JOSTEVE ADEKANBI",
style: TextStyle(
color: Colors.white,
),
)
],
),
),
Image.asset(
"assets/images/mastercardlogo.png",
height: 30,
)
],
),
],
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: LinearGradient(
colors: [Colors.pink, Colors.purple],
),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
blurRadius: 1.0,
spreadRadius: 1.0,
),
],
),
);
}
}
Then we have this:
Now in order to place the card in a slant position, we'll use Transform
to
rotate the card on the y, x, z axis respectively and translate x.
class ATMCard extends StatelessWidget {
const ATMCard({
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.0008)
..rotateY(0.5)
..rotateX(-0.8)
..rotateZ(0.1)
..translate(20.0),
child: ATMCardUI(),
);
}
}
Then we have this:
Adding more cards and spacing them out
We'll use a Stack
to put the cards on each other
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
int numberOfCards = 5;
return Scaffold(
backgroundColor: Colors.black,
body: Column(
children: List.generate(
numberOfCards,
(index) {
return ATMCard();
},
),
),
);
}
}
Then we need to move (translate) each card such that the immediate card above, is tends towards the left and top of the current card. To do this, we'll need the index and reverseIndex of each card.
double reverseIndex = numberOfCards - index.toDouble();
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
int numberOfCards = 5;
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: List.generate(
numberOfCards,
(index) {
double reverseIndex = numberOfCards - index.toDouble();
return ATMCard(
reverseIndex: reverseIndex,
index: index.toDouble(),
);
},
),
),
),
);
}
}
class ATMCard extends StatelessWidget {
final double index;
final double reverseIndex;
const ATMCard({
Key key,
this.index,
this.reverseIndex,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(
(index * -20.0),
reverseIndex * 30.0,
),
child: Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.0008)
..rotateY(0.5)
..rotateX(-0.8)
..rotateZ(0.1)
..translate(20.0),
child: ATMCardUI(),
),
);
}
}
Then we have this:
Animating the top most card
The top most card is the card at the last index of the generated list.
There are 2 steps here:
- Place the card in a position that looks fit.
- Animate the card to that position.
We'll update the ATMCard
widget so we can pass custom values for rotateY, rotateX and Y translation.
class ATMCard extends StatelessWidget {
final double index;
final double reverseIndex;
final double rotateX, rotateY, translateY;
const ATMCard({
Key key,
this.index,
this.reverseIndex,
this.rotateX,
this.rotateY,
this.translateY,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(
(index * -20.0),
reverseIndex * 30.0,
),
child: Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.0008)
..rotateY(rotateY ?? 0.5)
..rotateX(rotateX ?? -0.8)
..rotateZ(0.1)
..translate(20.0, translateY ?? 0.0),
child: ATMCardUI(),
),
);
}
}
Since we can pass custom values we can then pass custom values for the top most card.
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> {
@override
Widget build(BuildContext context) {
int numberOfCards = 5;
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: List.generate(
numberOfCards,
(index) {
double reverseIndex = numberOfCards - index.toDouble();
//top most card.
if(index == numberOfCards - 1){
return ATMCard(
rotateY: 0.6,
rotateX: -0.1,
translateY: -300,
reverseIndex: reverseIndex,
index: index.toDouble(),
);
}
return ATMCard(
reverseIndex: reverseIndex,
index: index.toDouble(),
);
},
),
),
),
);
}
}
Then we have this:
Now, we want to animate the card from the initial position to the new position. Recall that we passed the initial position directly in the ATMCard
widget.
We'll introduce a method getAnimValue
that returns value from start
to end
based on the Animation
passed.
class _HomeState extends State<Home> with TickerProviderStateMixin {
AnimationController _moveController;
@override
void initState() {
super.initState();
_moveController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 1000),
);
}
@override
void dispose() {
_moveController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
int numberOfCards = 5;
return Scaffold(
backgroundColor: Colors.black,
floatingActionButton: FloatingActionButton(
onPressed: (){
_moveController.isDismissed ? _moveController.forward(): _moveController.reverse();
},
),
body: Center(
child: AnimatedBuilder(
animation: _moveController,
builder: (context, snapshot) {
return Stack(
children: List.generate(
numberOfCards,
(index) {
double reverseIndex = numberOfCards - index.toDouble();
var yRotate = getAnimValue(
start: 0.5, end: 0.6, animation: _moveController);
var xRotate = getAnimValue(
start: -0.8, end: -0.1, animation: _moveController);
var yTranslate = -300 * _moveController.value;
if (index == numberOfCards - 1) {
return ATMCard(
rotateY: yRotate,
rotateX: xRotate ,
translateY: yTranslate,
reverseIndex: reverseIndex,
index: index.toDouble(),
);
}
return ATMCard(
reverseIndex: reverseIndex,
index: index.toDouble(),
);
},
),
);
},
),
),
);
}
double getAnimValue(
{double start, double end, Animation animation}) {
return ((end - start) * animation.value) + start;
}
}
Then we have this:
Animating the bottom most card
We'll add an extra card to serve as the bottom most card.
The bottom most card is the card at index 0 of the generated list.
See the bottom most card as a replica of top most card, the only difference is the position. It is used so when the card falls, it falls behind the other cards since it's at index 0 (it is behind every card).
Doing this, the topmost card will now be at index
numberOfCards
.
We want to animate the bottom most card to the same position as the top most.
class _HomeState extends State<Home> with TickerProviderStateMixin {
AnimationController _moveController;
@override
void initState() {
super.initState();
_moveController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 1000),
);
}
@override
void dispose() {
_moveController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
int numberOfCards = 5;
return Scaffold(
backgroundColor: Colors.black,
floatingActionButton: FloatingActionButton(
onPressed: () {
_moveController.isDismissed
? _moveController.forward()
: _moveController.reverse();
},
),
body: Center(
child: AnimatedBuilder(
animation: _moveController,
builder: (context, snapshot) {
return Stack(
children: List.generate(
numberOfCards + 1,
(index) {
double reverseIndex = (numberOfCards + 1) - index.toDouble();
var yRotate = getAnimValue(
start: 0.5, end: 0.6, animation: _moveController);
var xRotate = getAnimValue(
start: -0.8, end: -0.1, animation: _moveController);
var yTranslate = -300 * _moveController.value;
// The top most card.
// Now at numberOfCards because we've added an extra card to generated list and we want to keep the top most card as the last item in the generated list.
if (index == numberOfCards) {
return ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: reverseIndex,
index: index.toDouble(),
);
}
double moveEnd = (numberOfCards + 1) - numberOfCards.toDouble();
double moveStart = reverseIndex;
if (index == 0) {
return ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: getAnimValue(start: moveStart, end: moveEnd, animation: _moveController),
index: numberOfCards * _moveController.value
);
}
return ATMCard(
reverseIndex: reverseIndex,
index: index.toDouble(),
);
},
),
);
},
),
),
);
}
double getAnimValue({double start, double end, Animation animation}) {
return ((end - start) * animation.value) + start;
}
}
Then we have:
The fall effect
We need 2 cards because we have to find a way to keep the top most card behind other cards but since the bottom most card is behind other cards, we can make it a replica of the top most card.
We'll introduce a new boolean variable isOut
isOut is true when the top most card is not in the initial position and false when it is in the initial position. It literally means
is out of the deck
What we want to do is:
- Hide the top most card when
isOut
and show the bottom most card whenisOut
. - Show the top most card when
!isOut
and hide the bottom most card when!isOut
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> with TickerProviderStateMixin {
AnimationController _moveController;
@override
void initState() {
super.initState();
_moveController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 1000),
);
}
@override
void dispose() {
_moveController.dispose();
super.dispose();
}
bool isOut = false;
void animate() async{
await _moveController.forward();
setState(() {
isOut = true;
});
await _moveController.reverse();
setState(() {
isOut = false;
});
}
@override
Widget build(BuildContext context) {
int numberOfCards = 5;
return Scaffold(
backgroundColor: Colors.black,
floatingActionButton: FloatingActionButton(
onPressed: () {
animate();
},
),
body: Center(
child: AnimatedBuilder(
animation: _moveController,
builder: (context, snapshot) {
return Stack(
children: List.generate(
numberOfCards + 1,
(index) {
double reverseIndex = (numberOfCards + 1) - index.toDouble();
var yRotate = getAnimValue(
start: 0.5, end: 0.6, animation: _moveController);
var xRotate = getAnimValue(
start: -0.8, end: -0.1, animation: _moveController);
var yTranslate = -300 * _moveController.value;
if (index == numberOfCards) {
return Opacity(
opacity: isOut ? 0 : 1,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: reverseIndex,
index: index.toDouble(),
),
);
}
double moveEnd = (numberOfCards + 1) - numberOfCards.toDouble();
double moveStart = reverseIndex;
if (index == 0) {
return Opacity(
opacity: isOut ? 1 : 0,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: getAnimValue(start: moveStart, end: moveEnd, animation: _moveController),
index: numberOfCards * _moveController.value
),
);
}
return ATMCard(
reverseIndex: reverseIndex,
index: index.toDouble(),
);
},
),
);
},
),
),
);
}
double getAnimValue({double start, double end, Animation animation}) {
return ((end - start) * animation.value) + start;
}
}
Then we have this:
Shifting other cards one step upward.
Before _moveController.reverse()
is done, other cards have to move one step upward so it looks like there's an empty slot at the bottom.
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> with TickerProviderStateMixin {
AnimationController _moveController;
AnimationController _shiftController;
@override
void initState() {
super.initState();
_shiftController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500));
_moveController = AnimationController(
vsync: this, duration: Duration(milliseconds: 1000));
}
Future<void> animate() async {
await _moveController.forward();
setState(() {
isOut = true;
});
_moveController.reverse();
await Future.delayed(Duration(milliseconds: 300));
_shiftController.forward();
await Future.delayed(Duration(seconds: 1));
}
@override
void dispose() {
_moveController.dispose();
_shiftController.dispose();
super.dispose();
}
bool isOut = false;
@override
Widget build(BuildContext context) {
int numberOfCards = 5;
return Scaffold(
backgroundColor: Colors.black,
floatingActionButton: FloatingActionButton(
onPressed: () {
animate();
},
),
body: Center(
child: AnimatedBuilder(
animation: _moveController,
builder: (_, __) {
return AnimatedBuilder(
animation: _shiftController,
builder: (context, snapshot) {
return Stack(
children: List.generate(
numberOfCards + 1,
(index) {
double reverseIndex =
(numberOfCards + 1) - index.toDouble();
var yRotate = getAnimValue(
start: 0.5, end: 0.6, animation: _moveController);
var xRotate = getAnimValue(
start: -0.8, end: -0.1, animation: _moveController);
var yTranslate = -300 * _moveController.value;
if (index == numberOfCards) {
return Opacity(
opacity: isOut ? 0 : 1,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: reverseIndex,
index: index.toDouble(),
),
);
}
double moveEnd =
(numberOfCards + 1) - numberOfCards.toDouble();
double moveStart = reverseIndex;
if (index == 0) {
return Opacity(
opacity: isOut ? 1 : 0,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: getAnimValue(
start: moveStart,
end: moveEnd,
animation: _moveController),
index: numberOfCards * _moveController.value),
);
}
return ATMCard(
reverseIndex: getAnimValue(
start: reverseIndex,
end: (numberOfCards + 1) - (index.toDouble() + 1),
animation: _shiftController,
),
index: getAnimValue(
start: index.toDouble(),
end: index.toDouble() + 1,
animation: _shiftController,
),
);
},
),
);
},
);
},
),
),
);
}
double getAnimValue({double start, double end, Animation animation}) {
return ((end - start) * animation.value) + start;
}
}
Then we have this:
But something is not right with the bottom most card.
- The problem: The bottom card is retaining its index.
- The solution: Since we moved other cards one step upward we should also move the bottom most card one step upward.
List.generate(
numberOfCards + 1,
(index) {
double reverseIndex = (numberOfCards + 1) - index.toDouble();
var yRotate =
getAnimValue(start: 0.5, end: 0.6, animation: _moveController);
var xRotate =
getAnimValue(start: -0.8, end: -0.1, animation: _moveController);
var yTranslate = -300 * _moveController.value;
if (index == numberOfCards) {
return Opacity(
opacity: isOut ? 0 : 1,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: reverseIndex,
index: index.toDouble(),
),
);
}
double moveEnd = (numberOfCards + 1) - numberOfCards.toDouble();
double moveStart = reverseIndex;
if (index == 0) {
return Opacity(
opacity: isOut ? 1 : 0,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: isOut
? getAnimValue(
animation: _moveController,
start: (numberOfCards + 1) - 1.0,
end: moveEnd)
: getAnimValue(
start: moveStart,
end: moveEnd,
animation: _moveController,
),
index: isOut
? getAnimValue(
start: 1.0,
end: numberOfCards.toDouble(),
animation: _moveController,
)
: numberOfCards * _moveController.value,
),
);
}
return ATMCard(
reverseIndex: getAnimValue(
start: reverseIndex,
end: (numberOfCards + 1) - (index.toDouble() + 1),
animation: _shiftController,
),
index: getAnimValue(
start: index.toDouble(),
end: index.toDouble() + 1,
animation: _shiftController,
),
);
},
)
Now we have this:
Completing the animation
Now we can have the animate
method as:
Future<void> animate() async {
await _moveController.forward();
setState(() {
isOut = true;
});
_moveController.reverse();
await Future.delayed(Duration(milliseconds: 300));
_shiftController.forward();
await Future.delayed(Duration(seconds: 1));
_shiftController.reset();
setState(() {
isOut = false;
});
}
Why
_shiftController.reset();
and not_shiftController.reverse();
?
This is what we'll get if we use .reverse()
Customising the cards
Let's create a class to hold the card UI details.
class ATMCardUIDetails {
final List<Color> gradientColors;
final String cardName;
final IconData cardIcon;
final String cardOwner;
final String cardPan;
final String validThru;
ATMCardUIDetails(
{this.gradientColors,
this.cardName,
this.cardIcon,
this.cardOwner,
this.cardPan,
this.validThru});
}
Then we use it.
class Home extends StatefulWidget {
@override
_HomeState createState() => _HomeState();
}
class _HomeState extends State<Home> with TickerProviderStateMixin {
AnimationController _moveController;
AnimationController _shiftController;
@override
void initState() {
super.initState();
_shiftController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500));
_moveController = AnimationController(
vsync: this, duration: Duration(milliseconds: 1000));
Timer.periodic(Duration(milliseconds: 3000), (timer) async {
await animate();
});
}
Future<void> animate() async {
await _moveController.forward();
setState(() {
isOut = true;
});
_moveController.reverse();
await Future.delayed(Duration(milliseconds: 300));
_shiftController.forward();
await Future.delayed(Duration(seconds: 1));
_shiftController.reset();
setState(() {
isOut = false;
cardsDetailsList.add(cardsDetailsList.removeAt(0));
});
}
List<ATMCardUIDetails> cardsDetailsList = [
ATMCardUIDetails(
cardIcon: CupertinoIcons.money_dollar_circle,
gradientColors: [Colors.indigo, Colors.purple],
cardName: "Dollar",
cardOwner: "TIM SNEATH",
cardPan: "1010967890181234",
),
ATMCardUIDetails(
cardIcon: CupertinoIcons.money_pound_circle,
gradientColors: [Colors.red, Colors.blue[700]],
cardName: "Pound",
cardOwner: "TIMILEHIN JEGEDE",
cardPan: "1010967900181112",
),
ATMCardUIDetails(
gradientColors: [Colors.pink, Colors.lime],
cardName: "Bitcoin",
cardIcon: CupertinoIcons.bitcoin_circle,
cardOwner: "LETS4R",
cardPan: "1010102412346789",
),
ATMCardUIDetails(
cardIcon: CupertinoIcons.money_euro_circle,
cardName: "Euro",
gradientColors: [
Colors.green,
Colors.cyan[700],
],
cardOwner: "CHIZIARUHOMA OGBONDA",
cardPan: "1010113567390789",
),
ATMCardUIDetails(
cardIcon: CupertinoIcons.money_yen_circle,
cardName: "Yen",
gradientColors: [Colors.blueGrey, Colors.brown],
cardPan: "1010345790908867",
),
ATMCardUIDetails(
cardIcon: CupertinoIcons.money_yen_circle,
cardName: "Yen",
gradientColors: [Colors.orange, Colors.indigoAccent],
cardPan: "1010345790908867",
cardOwner: "WILSON WILSON"),
ATMCardUIDetails(
cardIcon: CupertinoIcons.bitcoin_circle_fill,
cardName: "Petra",
gradientColors: [Colors.blueAccent, Colors.black],
cardPan: "1010345790908890",
cardOwner: "EMMANUEL ADEBAYO",
),
];
@override
void dispose() {
_moveController.dispose();
_shiftController.dispose();
super.dispose();
}
bool isOut = false;
@override
Widget build(BuildContext context) {
int numberOfCards = cardsDetailsList.length;
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: AnimatedBuilder(
animation: _moveController,
builder: (_, __) {
return AnimatedBuilder(
animation: _shiftController,
builder: (context, snapshot) {
return Stack(
children: List.generate(
numberOfCards + 1,
(index) {
double reverseIndex =
(numberOfCards + 1) - index.toDouble();
var yRotate = getAnimValue(
start: 0.5, end: 0.6, animation: _moveController);
var xRotate = getAnimValue(
start: -0.8, end: -0.1, animation: _moveController);
var yTranslate = -300 * _moveController.value;
if (index == numberOfCards) {
return Opacity(
opacity: isOut ? 0 : 1,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
reverseIndex: reverseIndex,
index: index.toDouble(),
atmCardUIDetails: cardsDetailsList.first,
),
);
}
double moveEnd =
(numberOfCards + 1) - numberOfCards.toDouble();
double moveStart = reverseIndex;
if (index == 0) {
return Opacity(
opacity: isOut ? 1 : 0,
child: ATMCard(
rotateY: yRotate,
rotateX: xRotate,
translateY: yTranslate,
atmCardUIDetails: cardsDetailsList.first,
reverseIndex: isOut
? getAnimValue(
animation: _moveController,
start: (numberOfCards + 1) - 1.0,
end: moveEnd)
: getAnimValue(
start: moveStart,
end: moveEnd,
animation: _moveController,
),
index: isOut
? getAnimValue(
start: 1.0,
end: numberOfCards.toDouble(),
animation: _moveController,
)
: numberOfCards * _moveController.value,
),
);
}
List<ATMCardUIDetails> leftDetails = [];
for (int i = cardsDetailsList.length - 1; i > 0; i--) {
leftDetails.add(cardsDetailsList[i]);
}
return ATMCard(
atmCardUIDetails: leftDetails[index - 1],
reverseIndex: getAnimValue(
start: reverseIndex,
end: (numberOfCards + 1) - (index.toDouble() + 1),
animation: _shiftController,
),
index: getAnimValue(
start: index.toDouble(),
end: index.toDouble() + 1,
animation: _shiftController,
),
);
},
),
);
},
);
},
),
),
);
}
double getAnimValue({double start, double end, Animation animation}) {
return ((end - start) * animation.value) + start;
}
}
class ATMCard extends StatelessWidget {
final double index;
final double reverseIndex;
final double rotateX, rotateY, translateY;
final ATMCardUIDetails atmCardUIDetails;
const ATMCard({
Key key,
this.index,
this.reverseIndex,
this.rotateX,
this.rotateY,
this.translateY,
this.atmCardUIDetails,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(
(index * -20.0),
reverseIndex * 30.0,
),
child: Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.0008)
..rotateY(rotateY ?? 0.5)
..rotateX(rotateX ?? -0.8)
..rotateZ(0.1)
..translate(20.0, translateY ?? 0.0),
child: ATMCardUI(
atmCardUIDetails: atmCardUIDetails,
),
),
);
}
}
class ATMCardUI extends StatelessWidget {
final ATMCardUIDetails atmCardUIDetails;
const ATMCardUI({Key key, this.atmCardUIDetails}) : super(key: key);
String get getCardPan {
return ("1234567890181234").replaceAllMapped(
RegExp(r".{4}"), (match) => "${match.group(0)} ");
}
Widget build(BuildContext context) {
return Container(
width: 450,
height: 280,
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
atmCardUIDetails.cardIcon ?? Icons.masks_rounded,
color: Colors.white,
size: 60,
),
Transform.translate(
offset: Offset(0, -2),
child: Text(
"${atmCardUIDetails.cardName ?? "Flutter"} Card",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
Spacer(),
Text(
getCardPan,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 25,
),
),
Spacer(),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"VALID\nTRU",
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
SizedBox(width: 10),
Text(
"10/21",
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w600,
),
)
],
),
SizedBox(height: 10),
Text(
atmCardUIDetails.cardOwner ?? "JOSTEVE ADEKANBI",
style: TextStyle(
color: Colors.white,
),
)
],
),
),
Image.asset(
"assets/images/mastercardlogo.png",
height: 30,
)
],
),
],
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: LinearGradient(
colors: atmCardUIDetails.gradientColors ??
[Colors.pink, Colors.purple],
),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
blurRadius: 1.0,
spreadRadius: 1.0,
),
],
),
);
}
}
Then we have:
It works but it's resetting??π
One last thing
We have to move the first item in cardsDetailsList
to end.
Future<void> animate() async {
await _moveController.forward();
setState(() {
isOut = true;
});
_moveController.reverse();
await Future.delayed(Duration(milliseconds: 300));
_shiftController.forward();
await Future.delayed(Duration(seconds: 1));
_shiftController.reset();
setState(() {
isOut = false;
cardsDetailsList.add(cardsDetailsList.removeAt(0)); //-> Remove it from the first position then add it again so it goes to the end.
});
}
Repeating the animation
We can repeat the animation with Timer.periodic
@override
void initState() {
super.initState();
_shiftController =
AnimationController(vsync: this, duration: Duration(milliseconds: 500));
_moveController = AnimationController(
vsync: this, duration: Duration(milliseconds: 1000));
Timer.periodic(Duration(milliseconds: 3000), (timer) async {
await animate();
});
}
Final Output
And thatβs it!
The source code is available Here.
Happy Fluttering! π