Flutter 实现探探卡片布局
前言
前几天写了一个 Fluter 插件 tcard,用来实现类似于探探卡片的布局。效果如下,本文讲解如何使用 Stack
控件实现这个布局。

初识 Stack
Stack
是一个有多子项的控件,它会将自己的子项相对于自身边缘进行定位,后面的子项会覆盖前面的子项。通常用来实现将一个控件覆盖于另一个控件之上的布局,比如在一张图片上显示一些文字。子项的默认位置在 Stack
左上角,也可以用 Align
或者 Positioned
控件分别进行定位。

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,
),
],
)
Stack (Flutter Widget of the Week)
布局思路
要使用 Stack
实现这个卡片布局的大致思路如下
- 首先需要前,中,后三个子控件,使用
Align
控件定位在容器中。 - 需要一个手势监听器
GestureDetector
监听手指滑动。 - 监听手指在屏幕上滑动同时更新最前面卡片的位置。
- 判断移动的横轴距离进行卡片位置变换动画或者卡片回弹动画。
- 如果运行了卡片位置变换动画在动画结束后更新卡片的索引值。
卡片布局
- 创建
Stack
容器以及前,中,后三个子控件
class MyApp extends StatefulWidget { [@override](/user/override) _MyAppState createState() => _MyAppState(); }
class _MyAppState extends State<MyApp> { // 前面的卡片,使用 Align 定位 Widget _frontCard() { return Align( child: Container( color: Colors.blue, ), ); }
// 中间的卡片,使用 Align 定位 Widget _middleCard() { return Align( child: Container( color: Colors.red, ), ); }
// 后面的卡片,使用 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: [ // 后面的子项会显示在上面,所以前面的卡片放在最后 _backCard(), _middleCard(), _frontCard(), ], ), ), ), ), ); } }

- 对子控件分别定位并设置其尺寸
定位需要设置 Align
控件的 alignment 属性,传入一个 Alignment(x, y)
进行设置。设置尺寸需要使用 LayoutBuilder
获取当前父容器的尺寸,然后根据容器尺寸进行计算。
class _MyAppState extends State<MyApp> { // 前面的卡片,使用 Align 定位 Widget _frontCard(BoxConstraints constraints) { return Align( alignment: Alignment(0.0, -0.5), // 使用 SizedBox 确定卡片尺寸 child: SizedBox.fromSize( // 计算卡片尺寸,相对于父容器 size: Size(constraints.maxWidth * 0.9, constraints.maxHeight * 0.9), child: Container( color: Colors.blue, ), ), ); }
// 中间的卡片,使用 Align 定位 Widget _middleCard(BoxConstraints constraints) { return Align( alignment: Alignment(0.0, 0.0), child: SizedBox.fromSize( // 计算卡片尺寸,相对于父容器 size: Size(constraints.maxWidth * 0.85, constraints.maxHeight * 0.9), child: Container( color: Colors.red, ), ), ); }
// 后面的卡片,使用 Align 定位 Widget _backCard(BoxConstraints constraints) { return Align( alignment: Alignment(0.0, 0.5), child: SizedBox.fromSize( // 计算卡片尺寸,相对于父容器 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) { // 使用 LayoutBuilder 获取容器的尺寸,传个子项计算卡片尺寸 return Stack( children: [ // 后面的子项会显示在上面,所以前面的卡片放在最后 _backCard(constraints), _middleCard(constraints), _frontCard(constraints), ], ); }, ), ), ), ), ); } }

- 更新最前面卡片位置
向 Stack
容器添加一个 GestureDetector
,手指在屏幕上移动时更新最前面卡片的位置。
class _MyAppState extends State<MyApp> { // 保存最前面卡片的定位 Alignment _frontCardAlignment = Alignment(0.0, -0.5); // 保存最前面卡片的旋转角度 double _frontCardRotation = 0.0;
// 前面的卡片,使用 Align 定位 Widget _frontCard(BoxConstraints constraints) { return Align( alignment: _frontCardAlignment, // 使用 Transform.rotate 旋转卡片 child: Transform.rotate( angle: (pi / 180.0) * _frontCardRotation, // 使用 SizedBox 确定卡片尺寸 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) { // 使用 LayoutBuilder 获取容器的尺寸,传个子项计算卡片尺寸 Size size = MediaQuery.of(context).size; double speed = 10.0;
return Stack( children: [ // 后面的子项会显示在上面,所以前面的卡片放在最后 _backCard(constraints), _middleCard(constraints), _frontCard(constraints), // 使用一个占满父元素的 GestureDetector 监听手指移动 SizedBox.expand( child: GestureDetector( onPanDown: (DragDownDetails details) {}, onPanUpdate: (DragUpdateDetails details) { // 手指移动就更新最前面卡片的 alignment 属性 _frontCardAlignment += Alignment( details.delta.dx / (size.width / 2) * speed, details.delta.dy / (size.height / 2) * speed, ); // 设置最前面卡片的旋转角度 _frontCardRotation = _frontCardAlignment.x; // setState 更新界面 setState(() {}); }, onPanEnd: (DragEndDetails details) {}, ), ), ], ); }, ), ), ), ), );
} }

卡片动画
这个布局有三种动画,最前面卡片移开的动画;后面两张卡片位置和尺寸变化的动画;最前面卡片回到原位的动画。
- 判断卡片横轴移动距离
在手指离开屏幕时判断卡片横轴的移动距离,如果最前面的卡片横轴移动距离超过限制就运行换位动画,否则运行回弹动画。
// 改变位置的动画 void _runChangeOrderAnimation() {}
// 卡片回弹的动画 void _runReboundAnimation(Offset pixelsPerSecond, Size size) {}
// 省略…
// 卡片横轴距离限制 final double limit = 10.0;
SizedBox.expand( child: GestureDetector( // 省略… onPanEnd: (DragEndDetails details) { // 如果最前面的卡片横轴移动距离超过限制就运行换位动画,否则运行回弹动画 if (_frontCardAlignment.x > limit || _frontCardAlignment.x < -limit) { _runChangeOrderAnimation(); } else { _runReboundAnimation( details.velocity.pixelsPerSecond, size, ); } }, ), ),
- 卡片回弹动画
首先实现卡片回弹的动画,使用 AnimationController
控制动画,在 initState
初始化动画控制器。创建一个 AlignmentTween
设置动画运动值,起始值是卡片当前位置,最终值是卡片的默认位置。然后将一个弹簧模拟 SpringSimulation
传递给动画控制器,让动画模拟运行。
class _MyAppState extends State<MyApp> with TickerProviderStateMixin { // 省略... // 卡片回弹动画 Animation<Alignment> _reboundAnimation; // 卡片回弹动画控制器 AnimationController _reboundController;
// 省略…
// 卡片回弹的动画 void _runReboundAnimation(Offset pixelsPerSecond, Size size) { // 创建动画值 _reboundAnimation = _reboundController.drive( AlignmentTween( // 起始值是卡片当前位置,最终值是卡片的默认位置 begin: _frontCardAlignment, end: Alignment(0.0, -0.5), ), ); // 计算卡片运动速度 final double unitsPerSecondX = pixelsPerSecond.dx / size.width; final double unitsPerSecondY = pixelsPerSecond.dy / size.height; final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY); final unitVelocity = unitsPerSecond.distance; // 创建弹簧模拟的定义 const spring = SpringDescription(mass: 30, stiffness: 1, damping: 1); // 创建弹簧模拟 final simulation = SpringSimulation(spring, 0, 1, -unitVelocity); // 根据给定的模拟运行动画 _reboundController.animateWith(simulation); // 重置旋转值 _frontCardRotation = 0.0; setState(() {}); }
@override void initState() { super.initState(); // 初始化回弹的动画控制器 _reboundController = AnimationController(vsync: this) …addListener(() { setState(() { // 动画运行时更新最前面卡片的 alignment 属性 _frontCardAlignment = _reboundAnimation.value; }); }); } // 省略… }

- 卡片换位动画
卡片换位动画就是将最前面的卡片移除可视区,将中间的卡片移动到最前面,将最后的卡片移动到中间,然后新建一个最后面的卡片。在卡片更换位置的同时需要改变卡片的尺寸,位置动画和尺寸动画同时进行。首先定义每个卡片运动时的动画值
/// 卡片尺寸 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); } }
/// 卡片位置 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); }
/// 卡片运动动画 class CardAnimations { /// 最前面卡片的消失动画值 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), ), ); }
/// 中间卡片位置变换动画值 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), ), ); }
/// 中间卡片尺寸变换动画值 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), ), ); }
/// 最后面卡片位置变换动画值 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), ), ); }
/// 最后面卡片尺寸变换动画值 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), ), ); } }
使用一个 AnimationController
控制动画运行,动画运行时在卡片上应用以上的动画值,否则使用卡片默认的位置和尺寸。
class _MyAppState extends State<MyApp> with TickerProviderStateMixin {
// 省略…
// 卡片位置变换动画控制器 AnimationController _cardChangeController;
// 前面的卡片,使用 Align 定位 Widget _frontCard(BoxConstraints constraints) { // 判断动画是否在运行 bool forward = _cardChangeController.status == AnimationStatus.forward;
// 使用 Transform.rotate 旋转卡片 Widget rotate = Transform.rotate( angle: (pi / 180.0) * _frontCardRotation, // 使用 SizedBox 确定卡片尺寸 child: SizedBox.fromSize( size: CardSizes.front(constraints), child: Container( color: Colors.blue, ), ), ); // 在动画运行时使用动画值 if (forward) { return Align( alignment: CardAnimations.frontCardDisappearAnimation( _cardChangeController, _frontCardAlignment, ).value, child: rotate, ); } // 否则使用默认值 return Align( alignment: _frontCardAlignment, child: rotate, );
}
// 中间的卡片,使用 Align 定位 Widget _middleCard(BoxConstraints constraints) { // 判断动画是否在运行 bool forward = _cardChangeController.status == AnimationStatus.forward; Widget child = Container(color: Colors.red);
// 在动画运行时使用动画值 if (forward) { return Align( alignment: CardAnimations.middleCardAlignmentAnimation( _cardChangeController, ).value, child: SizedBox.fromSize( size: CardAnimations.middleCardSizeAnimation( _cardChangeController, constraints, ).value, child: child, ), ); } // 否则使用默认值 return Align( alignment: CardAlignments.middle, child: SizedBox.fromSize( size: CardSizes.middle(constraints), child: child, ), );
}
// 后面的卡片,使用 Align 定位 Widget _backCard(BoxConstraints constraints) { // 判断动画是否在运行 bool forward = _cardChangeController.status == AnimationStatus.forward; Widget child = Container(color: Colors.green);
// 在动画运行时使用动画值 if (forward) { return Align( alignment: CardAnimations.backCardAlignmentAnimation( _cardChangeController, ).value, child: SizedBox.fromSize( size: CardAnimations.backCardSizeAnimation( _cardChangeController, constraints, ).value, child: child, ), ); } // 否则使用默认值 return Align( alignment: CardAlignments.back, child: SizedBox.fromSize( size: CardSizes.back(constraints), child: child, ), );
}
// 改变位置的动画 void _runChangeOrderAnimation() { _cardChangeController.reset(); _cardChangeController.forward(); }
// 省略…
@override void initState() { super.initState(); // 省略…
// 初始化卡片换位动画控制器 _cardChangeController = AnimationController( duration: Duration(milliseconds: 1000), vsync: this, ) ..addListener(() => setState(() {})) ..addStatusListener((status) { if (status == AnimationStatus.completed) { // 动画运行结束后重置位置和旋转 _frontCardRotation = 0.0; _frontCardAlignment = CardAlignments.front; setState(() {}); } });
} // 省略… }

数据更新
主题内容长度不能超过 20000 个字符。。。超长限制,全文地址在此 用 Flutter 实现探探卡片布局
Flutter 实现探探卡片布局
更多关于Flutter 实现探探卡片布局的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html
直接感谢了,把东西码到 v2 也不容易。
学习了,大佬牛逼
哈哈,取笑了,不是大佬
牛逼,大佬带带我,最近也想要深入这方面开发
大佬个屁啊…我也只是感兴趣而已;可以加我微信 xrr20160808 拉你进大佬群学习
在Flutter中实现探探卡片布局(Tinder-like card layout),你可以使用SwipeableStack
或类似的自定义组件来实现左右滑动卡片的效果。以下是一个简要的实现思路:
-
依赖库: 可以使用
flutter_swiper
或flutter_tinder_card
等第三方库,这些库已经封装好了滑动卡片的功能,可以减少开发工作量。 -
自定义组件: 如果希望更灵活地控制动画效果或布局,可以自定义一个
Stack
组件,结合GestureDetector
和AnimatedContainer
来实现滑动动画。 -
数据绑定: 使用Flutter的状态管理(如
Provider
、Riverpod
或GetX
)来管理卡片数据,以便在滑动时更新视图。 -
动画效果: 使用
AnimationController
和Tween
来控制卡片滑动时的动画效果,如缩放、旋转等。 -
处理滑动事件: 在
GestureDetector
的onPanUpdate
回调中处理滑动距离,当滑动距离达到一定阈值时,触发左滑或右滑的事件,同时更新卡片堆栈。 -
边界处理: 在卡片滑动到边界时,触发加载更多卡片或显示“没有更多卡片”的提示。
通过上述步骤,你可以在Flutter中实现一个类似于探探的卡片布局。需要注意的是,滑动动画的流畅性和响应速度对于用户体验至关重要,因此在实现过程中要特别注意性能优化。