My FeedDiscussionsHeadless CMS
New
Sign in
Log inSign up
Learn more about Hashnode Headless CMSHashnode Headless CMS
Collaborate seamlessly with Hashnode Headless CMSΒ for Enterprise.
Upgrade ✨Learn more
Implementing Flutterwave Animated Card Deck in Flutter

Implementing Flutterwave Animated Card Deck in Flutter

Josteve Adekanbi's photo
Josteve Adekanbi
Β·Mar 9, 2021Β·

26 min read

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: Screenshot 2021-03-09 at 05.35.01.png

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: Screenshot 2021-03-09 at 10.31.02.png

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: Screenshot 2021-03-09 at 10.40.17.png

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: Screenshot 2021-03-09 at 11.00.55.png

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: ezgif.com-video-to-gif-2.gif

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:

ezgif.com-video-to-gif-3.gif

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 when isOut.
  • 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: ezgif.com-video-to-gif-4.gif

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: ezgif.com-video-to-gif-5.gif

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:

ezgif.com-video-to-gif-6.gif

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()

ezgif.com-video-to-gif-7.gif


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:

ezgif.com-video-to-gif-9.gif

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

ezgif.com-video-to-gif-10.gif

And that’s it!
The source code is available Here.

Happy Fluttering! πŸ’™