I'm developing a Flutter app where I need a scroll card screen with a smooth animation. I tried using the multiple card package, but the animation isn't matching my reference video. Specifically, the card rotation, scaling, and stacking effects aren't as fluid and natural as in the video.
1 Answer
This example is just to illustrates the card effect and a way of animating it. There are multiple ways to apply transforms to a widget, the ideal one will depend on how may of those effects you want to mimic. The main reason reason I'm using Flow is because using it yields a shorter example.
The matrix transform just scales, shifts and rotates (axis and perspective) the cards. The list of widgets will be painted in order regardless of what the delegate does, i.e, the list index is the "z-index". I don't use transforms that often so my knowledge is quite basic. If you want a simpler version, check out this article.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
void main() => runApp(App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: FlowCardStack(
children: List.generate(5, (i) => MyCard(index: i)), //
),
),
);
}
}
class MyCard extends StatelessWidget {
const MyCard({super.key, required this.index});
final int index;
@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragDown: (_) => print('Card drag $index'),
// This gesture will win the arena as expected, the pointers will try
// to hit the correct shape (the one you see in the screen)
onVerticalDragUpdate: (details) {
FlowCardStackNotification(details.delta.dy, index).dispatch(context);
},
child: Container(
height: 200,
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 25),
decoration: BoxDecoration(
boxShadow: kElevationToShadow[index],
borderRadius: BorderRadius.all(Radius.circular(10)),
color: index.isEven ? Colors.blue : Colors.blueGrey,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text('Card $index'),
CircularProgressIndicator(), //
],
),
),
);
}
}
// Bare-bone basic notification
class FlowCardStackNotification extends Notification {
const FlowCardStackNotification(this.offset, [this.identifier]);
final double offset;
final Object? identifier;
}
class FlowCardStack extends StatefulWidget {
const FlowCardStack({super.key, required this.children});
final List<Widget> children;
@override
FlowCardStackState createState() => FlowCardStackState();
}
class FlowCardStackState extends State<FlowCardStack> with SingleTickerProviderStateMixin {
final notifier = ValueNotifier<double>(30);
@override
Widget build(BuildContext context) {
return NotificationListener<FlowCardStackNotification>(
onNotification: (notification) {
notifier.value = clampDouble(
// n - 1 scale down to match the last card, not the second one
notifier.value + (notification.offset / widget.children.length),
10,
200, //
);
return true;
},
child: Flow(
delegate: FlowCardStackDelegate(notifier: notifier),
children: widget.children, //
),
);
}
}
class FlowCardStackDelegate extends FlowDelegate {
FlowCardStackDelegate({required this.notifier}) : super(repaint: notifier);
final ValueNotifier<double> notifier;
@override
bool shouldRepaint(FlowCardStackDelegate old) {
return notifier != old.notifier;
}
// These values can be dynamic if a fancy-er animation is needed
final double scale_step = 0.05;
final double perspective_y_rotation = 0.001;
@override
void paintChildren(FlowPaintingContext context) {
final count = context.childCount;
for (int i = 0; i < count; ++i) {
final size = context.getChildSize(i)!;
final translation = FractionalOffset.center.alongSize(size);
context.paintChild(
i,
transform:
Matrix4.identity()
..setTranslationRaw(
(context.size.width - size.width) / 2,
notifier.value * i,
0, //
)
..translate(translation.dx, translation.dy)
..multiply(
Matrix4.zero()
..setIdentity()
..rotateX((pi / 180) * 10 * (count - i)) // +10 degrees per card
..setEntry(
3,
1,
// Incremental shift towards 0, the first value is
// one step less than the base value.
// If the +1 is removed the last card will have
// on step of rotation.
(perspective_y_rotation / count) * (count - (i + 1)),
)
..scale(
clampDouble(1 - scale_step * (count - i), 0, 1),
clampDouble(1 - scale_step * (count - i), 0, 1), //
),
)
..translate(-translation.dx, -translation.dy),
);
}
}
}

Transformor aMatrixTransition.Transformyou just need to use the alignment and the matrix with the rotation.