preface

A few days ago, I wrote a Fluter plug-in tcard, which is used to achieve a similar layout to probe card. This article explains how to use the Stack control to achieve this layout.

online

I met the Stack

A Stack is a control that has multiple children. It positions its children relative to its own edge, and the following children overwrite the previous children. Commonly used to implement layouts that overlay one control on top of another, such as displaying some text on a picture. The default position of the child is in the upper left corner of the Stack, and can also be Positioned using the Align or Positioned control, respectively.

Stack(
  children: <Widget>[
    Container(
      width: 100.      height: 100. color: Colors.red,  ),  Container(  width: 90. height: 90. color: Colors.green,  ),  Container(  width: 80. height: 80. color: Colors.blue,  ), ].) Copy the code

Stack (Flutter Widget of the Week)

layout

The general idea for using Stack to implement this card layout is as follows

  1. First need front, middle, after three child controls, useAlignThe control is positioned in a container.
  2. You need a gesture listenerGestureDetectorListen for finger swipes.
  3. Listen for your finger to slide across the screen while updating the position of the uppermost card.
  4. Judge the distance of the horizontal axis to move the card position transformation animation or card rebound animation.
  5. Update the index value of the card after the animation ends if the card position change animation is run.

Card layout

  1. createStackContainer and the first, middle, and last three child controls
class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {  // For the preceding card, use Align  Widget _frontCard() {  return Align(  child: Container(  color: Colors.blue,  ),  );  }   // For the middle card, use Align  Widget _middleCard() {  return Align(  child: Container(  color: Colors.red,  ),  );  }   // For the next card, use Align  Widget _backCard() {  return Align(  child: Container(  color: Colors.green,  ),  );  }   @override  Widget build(BuildContext context) {  return MaterialApp(  title: 'TCards demo'. debugShowCheckedModeBanner: false. home: Scaffold(  body: Center(  child: SizedBox(  width: 300. height: 400. child: Stack(  children: [  // Subsequent subitems are displayed above, so previous cards are placed last  _backCard(),  _middleCard(),  _frontCard(), ]. ),  ),  ),  ),  );  } } Copy the code

  1. Position and size child controls separately

Alignment Requires setting the alignment property of the Align control. Pass in an alignment (x, y) to set the alignment. To set the size, use LayoutBuilder to get the size of the current parent container, and then calculate it based on the container size.

class _MyAppState extends State<MyApp> {
  // For the preceding card, use Align
  Widget _frontCard(BoxConstraints constraints) {
    return Align(
      alignment: Alignment(0.0.0.5),
 // Use the SizedBox to determine the card size  child: SizedBox.fromSize(  // Calculate the card size relative to the parent container  size: Size(constraints.maxWidth * 0.9, constraints.maxHeight * 0.9),  child: Container(  color: Colors.blue,  ),  ),  );  }   // For the middle card, use Align  Widget _middleCard(BoxConstraints constraints) {  return Align(  alignment: Alignment(0.0.0.0),  child: SizedBox.fromSize(  // Calculate the card size relative to the parent container  size: Size(constraints.maxWidth * 0.85, constraints.maxHeight * 0.9),  child: Container(  color: Colors.red,  ),  ),  );  }   // For the next card, use Align  Widget _backCard(BoxConstraints constraints) {  return Align(  alignment: Alignment(0.0.0.5),  child: SizedBox.fromSize(  // Calculate the card size relative to the parent container  size: Size(constraints.maxWidth * 0.8, constraints.maxHeight * 9.),  child: Container(  color: Colors.green,  ),  ),  );  }   @override  Widget build(BuildContext context) {  return MaterialApp(  title: 'TCards demo'. debugShowCheckedModeBanner: false. home: Scaffold(  body: Center(  child: SizedBox(  width: 300. height: 400. child: LayoutBuilder(  builder: (context, constraints) {  // Use LayoutBuilder to get the container size, pass the subitems to calculate the card size  return Stack(  children: [  // Subsequent subitems are displayed above, so previous cards are placed last  _backCard(constraints),  _middleCard(constraints),  _frontCard(constraints), ]. );  },  ),  ),  ),  ),  );  } }  Copy the code

  1. Update the front card position

Add a GestureDetector to the Stack container that updates the position of the uppermost card as your finger moves across the screen.

class _MyAppState extends State<MyApp> {
  // Save the position of the first card
  Alignment _frontCardAlignment = Alignment(0.0.0.5);
  // Save the rotation Angle of the uppermost card
  double _frontCardRotation = 0.0;
  // For the preceding card, use Align  Widget _frontCard(BoxConstraints constraints) {  return Align(  alignment: _frontCardAlignment,  Rotate the card with transform.rotate  child: Transform.rotate(  angle: (pi / 180.0) * _frontCardRotation,  // Use the SizedBox to determine the card size  child: SizedBox.fromSize(  size: Size(constraints.maxWidth * 0.9, constraints.maxHeight * 0.9),  child: Container(  color: Colors.blue,  ),  ),  ),  );  }   // 省略......   @override  Widget build(BuildContext context) {  return MaterialApp(  title: 'TCards demo'. debugShowCheckedModeBanner: false. home: Scaffold(  body: Center(  child: SizedBox(  width: 300. height: 400. child: LayoutBuilder(  builder: (context, constraints) {  // Use LayoutBuilder to get the container size, pass the subitems to calculate the card size  Size size = MediaQuery.of(context).size;  double speed = 10.0;   return Stack(  children: [  // Subsequent subitems are displayed above, so previous cards are placed last  _backCard(constraints),  _middleCard(constraints),  _frontCard(constraints),  // Use a GestureDetector full of parent elements to listen for finger movements  SizedBox.expand(  child: GestureDetector(  onPanDown: (DragDownDetails details) {},  onPanUpdate: (DragUpdateDetails details) {  // The finger movement updates the alignment attribute of the front card  _frontCardAlignment += Alignment(  details.delta.dx / (size.width / 2) * speed,  details.delta.dy / (size.height / 2) * speed,  );  // Set the rotation Angle of the front card  _frontCardRotation = _frontCardAlignment.x;  // setState update interface  setState(() {});  },  onPanEnd: (DragEndDetails details) {},  ),  ), ]. );  },  ),  ),  ),  ),  );  } } Copy the code

Card animation

This layout has three kinds of animation, the first card removed animation; Animation of the position and size of the next two cards; Front card back to original animation.

  1. Judge how far the card moves along the horizontal axis

Determine how far the horizontal axis of the card moves when your finger is away from the screen. If the front card moves beyond the limit, run the transposition animation, otherwise run the rebound animation.

// Animation to change position
void _runChangeOrderAnimation() {}

// The card bounces back
void _runReboundAnimation(Offset pixelsPerSecond, Size size) {}
 / / to omit...  // Card horizontal distance limit final double limit = 10.0;  SizedBox.expand(  child: GestureDetector(  / / to omit...  onPanEnd: (DragEndDetails details) {  // If the front card moves beyond the limit, run the transpose animation, otherwise run the rebound animation  if (_frontCardAlignment.x > limit ||  _frontCardAlignment.x < -limit) {  _runChangeOrderAnimation();  } else {  _runReboundAnimation(  details.velocity.pixelsPerSecond,  size,  );  }  },  ), ), Copy the code
  1. Card rebound animation

First implement the card rebound animation, using AnimationController to control the animation, initState initialization of the AnimationController. Create a AlignmentTween to set the animation movement value, starting with the card’s current position and ending with the card’s default position. A SpringSimulation is then passed to the animation controller to let the animation simulation run.

class _MyAppState extends State<MyApp> with TickerProviderStateMixin {
  / / to omit...
  // Card rebound animation
  Animation<Alignment> _reboundAnimation;
  // Card rebound animation controller
 AnimationController _reboundController;   / / to omit...   // The card bounces back  void _runReboundAnimation(Offset pixelsPerSecond, Size size) {  // Create an animation value  _reboundAnimation = _reboundController.drive(  AlignmentTween(  // The starting value is the current card position, and the final value is the default card position  begin: _frontCardAlignment,  end: Alignment(0.0.0.5),  ),  );  // Calculate the speed of the card  final double unitsPerSecondX = pixelsPerSecond.dx / size.width;  final double unitsPerSecondY = pixelsPerSecond.dy / size.height;  final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);  final unitVelocity = unitsPerSecond.distance;  // Create a spring simulation definition  const spring = SpringDescription(mass: 30, stiffness: 1, damping: 1);  // Create spring simulation  final simulation = SpringSimulation(spring, 0.1, -unitVelocity);  // Run the animation based on the given simulation  _reboundController.animateWith(simulation);  // Reset the rotation value  _frontCardRotation = 0.0;  setState(() {});  }   @override  void initState() {  super.initState();  // Initialize the springback animation controller  _reboundController = AnimationController(vsync: this)  ..addListener(() {  setState(() {  // Updates the alignment property of the uppermost card while the animation is running  _frontCardAlignment = _reboundAnimation.value;  });  });  }  / / to omit... } Copy the code

  1. Card transposition animation

A card swap animation removes the front card from view, moves the middle card to the front, moves the last card to the middle, and creates a new last card. The size of the card needs to be changed at the same time as the position of the card is changed. Start by defining the animation value for each card as it moves

/// Card size
class CardSizes {
  static Size front(BoxConstraints constraints) {
    return Size(constraints.maxWidth * 0.9, constraints.maxHeight * 0.9);
  }
  static Size middle(BoxConstraints constraints) {  return Size(constraints.maxWidth * 0.85, constraints.maxHeight * 0.9);  }   static Size back(BoxConstraints constraints) {  return Size(constraints.maxWidth * 0.8, constraints.maxHeight * 9.);  } }  /// Card position class CardAlignments {  static Alignment front = Alignment(0.0.0.5);  static Alignment middle = Alignment(0.0.0.0);  static Alignment back = Alignment(0.0.0.5); }  /// card motion animation class CardAnimations {  /// The vanishing animation value of the uppermost card  static Animation<Alignment> frontCardDisappearAnimation(  AnimationController parent,  Alignment beginAlignment,  ) {  return AlignmentTween(  begin: beginAlignment,  end: Alignment(  beginAlignment.x > 0  ? beginAlignment.x + 30.0  : beginAlignment.x - 30.0. 0.0. ),  ).animate(  CurvedAnimation(  parent: parent,  curve: Interval(0.0.0.5, curve: Curves.easeIn),  ),  );  }   /// the middle card position transforms the animation value  static Animation<Alignment> middleCardAlignmentAnimation(  AnimationController parent,  ) {  return AlignmentTween(  begin: CardAlignments.middle,  end: CardAlignments.front,  ).animate(  CurvedAnimation(  parent: parent,  curve: Interval(0.2.0.5, curve: Curves.easeIn),  ),  );  }   /// the middle card size changes the animation value  static Animation<Size> middleCardSizeAnimation(  AnimationController parent,  BoxConstraints constraints,  ) {  return SizeTween(  begin: CardSizes.middle(constraints),  end: CardSizes.front(constraints),  ).animate(  CurvedAnimation(  parent: parent,  curve: Interval(0.2.0.5, curve: Curves.easeIn),  ),  );  }   /// the last card position changes the animation value  static Animation<Alignment> backCardAlignmentAnimation(  AnimationController parent,  ) {  return AlignmentTween(  begin: CardAlignments.back,  end: CardAlignments.middle,  ).animate(  CurvedAnimation(  parent: parent,  curve: Interval(0.4.0.7, curve: Curves.easeIn),  ),  );  }   /// the last card size changes the animation value  static Animation<Size> backCardSizeAnimation(  AnimationController parent,  BoxConstraints constraints,  ) {  return SizeTween(  begin: CardSizes.back(constraints),  end: CardSizes.middle(constraints),  ).animate(  CurvedAnimation(  parent: parent,  curve: Interval(0.4.0.7, curve: Curves.easeIn),  ),  );  } } Copy the code

Use an AnimationController to control the animation run, and apply the above animation values to the card while the animation runs, otherwise use the default position and size of the card.

class _MyAppState extends State<MyApp> with TickerProviderStateMixin {

  / / to omit...

  // Card position change animation controller
 AnimationController _cardChangeController;   // For the preceding card, use Align  Widget _frontCard(BoxConstraints constraints) {  // Check if the animation is running  bool forward = _cardChangeController.status == AnimationStatus.forward;   Rotate the card with transform.rotate  Widget rotate = Transform.rotate(  angle: (pi / 180.0) * _frontCardRotation,  // Use the SizedBox to determine the card size  child: SizedBox.fromSize(  size: CardSizes.front(constraints),  child: Container(  color: Colors.blue,  ),  ),  );   // Use the animation value when the animation is running  if (forward) {  return Align(  alignment: CardAnimations.frontCardDisappearAnimation(  _cardChangeController,  _frontCardAlignment,  ).value,  child: rotate,  );  }   // Otherwise, use the default values  return Align(  alignment: _frontCardAlignment,  child: rotate,  );  }   // For the middle card, use Align  Widget _middleCard(BoxConstraints constraints) {  // Check if the animation is running  bool forward = _cardChangeController.status == AnimationStatus.forward;  Widget child = Container(color: Colors.red);   // Use the animation value when the animation is running  if (forward) {  return Align(  alignment: CardAnimations.middleCardAlignmentAnimation(  _cardChangeController,  ).value,  child: SizedBox.fromSize(  size: CardAnimations.middleCardSizeAnimation(  _cardChangeController,  constraints,  ).value,  child: child,  ),  );  }   // Otherwise, use the default values  return Align(  alignment: CardAlignments.middle,  child: SizedBox.fromSize(  size: CardSizes.middle(constraints),  child: child,  ),  );  }   // For the next card, use Align  Widget _backCard(BoxConstraints constraints) {  // Check if the animation is running  bool forward = _cardChangeController.status == AnimationStatus.forward;  Widget child = Container(color: Colors.green);   // Use the animation value when the animation is running  if (forward) {  return Align(  alignment: CardAnimations.backCardAlignmentAnimation(  _cardChangeController,  ).value,  child: SizedBox.fromSize(  size: CardAnimations.backCardSizeAnimation(  _cardChangeController,  constraints,  ).value,  child: child,  ),  );  }   // Otherwise, use the default values  return Align(  alignment: CardAlignments.back,  child: SizedBox.fromSize(  size: CardSizes.back(constraints),  child: child,  ),  );  }   // Animation to change position  void _runChangeOrderAnimation() {  _cardChangeController.reset();  _cardChangeController.forward();  }   / / to omit...   @override  void initState() {  super.initState();  / / to omit...   // Initializes the card swap animation controller  _cardChangeController = AnimationController(  duration: Duration(milliseconds: 1000),  vsync: this. )  ..addListener(() => setState(() {}))  ..addStatusListener((status) {  if (status == AnimationStatus.completed) {  // Reset position and rotation after animation runs  _frontCardRotation = 0.0;  _frontCardAlignment = CardAlignments.front;  setState(() {});  }  });  }  / / to omit... } Copy the code

Data update

You can see that after the animation runs, all three cards are restored to their default positions and sizes, but the desired effect is that the data of the three cards will change after the card transposition animation is completed, so data processing needs to be done after the animation.

Create an array to hold all of the subitems, update the index of the subitem of the uppermost card with an index, incrementing the index value by one after the card swap animation ends.

List<String> images = [
  'https://gank.io/images/5ba77f3415b44f6c843af5e149443f94'.  'https://gank.io/images/02eb8ca3297f4931ab64b7ebd7b5b89c'.  'https://gank.io/images/31f92f7845f34f05bc10779a468c3c13'.  'https://gank.io/images/b0f73f9527694f44b523ff059d8a8841'. 'https://gank.io/images/1af9d69bc60242d7aa2e53125a4586ad'.];  // Generate an array of cards List<Widget> cards = List.generate(  images.length,  (int index) {  return Container(  decoration: BoxDecoration(  color: Colors.white,  borderRadius: BorderRadius.circular(16.0),  boxShadow: [  BoxShadow(  offset: Offset(0.17),  blurRadius: 23.0. spreadRadius: 13.0. color: Colors.black54,  ) ]. ),  child: ClipRRect(  borderRadius: BorderRadius.circular(16.0),  child: Image.network(  images[index],  fit: BoxFit.cover,  ),  ),  );  }, );  void main() {  // Use the generated card array  runApp(MyApp(cards: cards)); }  class MyApp extends StatefulWidget {  final List<Widget> cards;   const MyApp({@required this.cards});   @override  _MyAppState createState() => _MyAppState(); }  class _MyAppState extends State<MyApp> with TickerProviderStateMixin {  // Card list  final List<Widget> _cards = [];  // Index of the uppermost card  int _frontCardIndex = 0;   / / to omit...   // For the preceding card, use Align  Widget _frontCard(BoxConstraints constraints) {  // Determine if there are any cards left  Widget card =  _frontCardIndex < _cards.length ? _cards[_frontCardIndex] : Container();  bool forward = _cardChangeController.status == AnimationStatus.forward;   Rotate the card with transform.rotate  Widget rotate = Transform.rotate(  angle: (pi / 180.0) * _frontCardRotation,  // Use the SizedBox to determine the card size  child: SizedBox.fromSize(  size: CardSizes.front(constraints),  // Use the subitems in the array  child: card,  ),  );   / / to omit...  }   // For the middle card, use Align  Widget _middleCard(BoxConstraints constraints) {  // Determine if there are two cards left  Widget card = _frontCardIndex < _cards.length - 1  ? _cards[_frontCardIndex + 1]  : Container();  / / to omit...  }   // For the next card, use Align  Widget _backCard(BoxConstraints constraints) {  // Check if there are three cards in the array  Widget card = _frontCardIndex < _cards.length - 2  ? _cards[_frontCardIndex + 2]  : Container();  / / to omit...  }   / / to omit...   @override  void initState() {  super.initState();  // Initialize the card array  _cards.addAll(widget.cards);   / / to omit...   // Initializes the card swap animation controller  _cardChangeController = AnimationController(  duration: Duration(milliseconds: 1000),  vsync: this. )  ..addListener(() => setState(() {}))  ..addStatusListener((status) {  if (status == AnimationStatus.completed) {  // Move the index of the uppermost card forward one bit after the animation ends  _frontCardIndex++;  // Reset position and rotation after animation runs  _frontCardRotation = 0.0;  _frontCardAlignment = CardAlignments.front;  setState(() {});  }  });  }   / / to omit...  return Stack(  children: [  / / to omit...   // Use a GestureDetector full of parent elements to listen for finger movements  // If the animation is running, it does not respond to gestures _cardChangeController.status ! = AnimationStatus.forward ? SizedBox.expand(  child: GestureDetector(  / / to omit...  )  )  : IgnorePointer(), ].}  Copy the code

At this point the entire layout is implemented 🎉

conclusion

The key to this layout is that

  1. Location of three cards
  2. Listen for gestures to update the position of the uppermost card
  3. Card transposition animation and rebound animation

The authors have to encapsulate the plug-in, the address is https://pub.dev/packages/tcard welcome to use.

reference

Stack class

tinder_cards

This article is formatted using MDNICE