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中实现一个类似于探探的卡片布局。需要注意的是,滑动动画的流畅性和响应速度对于用户体验至关重要,因此在实现过程中要特别注意性能优化。