Flutter 超炫折叠式卡片

巴格蛋丝哔哩哔哩教程地址

原作者 巴格蛋丝的源代码下载

源码下载

依赖包

  • flutter_rating_bar

IOS版本 github

基础组件

三列文字组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Widget explainText(String title, String subTitle, {Color? titleColor, Color? subTitleColor}) {
return Text.rich(
TextSpan(
style: const TextStyle(
height: 1.4,
),
children: [
TextSpan(
text: '$title\n',
style: TextStyle(
color: titleColor ?? Colors.grey,
fontSize: 13,
),
),
TextSpan(
text: subTitle,
style: TextStyle(
color: subTitleColor ?? Colors.blueGrey,
fontWeight: FontWeight.bold,
fontSize: 17,
),
)
],
),
);
}

三行文字组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Widget multipleLineText(String line1, String line2, String line3) {
return Text.rich(
TextSpan(
style: TextStyle(height: 1.4),
children: [
TextSpan(
text: '$line1\n',
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
TextSpan(
text: '$line2\n',
style: const TextStyle(
color: Colors.black87,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
TextSpan(
text: line3,
style: const TextStyle(
color: Colors.black87,
fontSize: 12,
),
),
],
),
);
}

组件1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
Widget component1() {
double height = 165.0;

return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
child: Container(
height: height,
child: Row(
children: [
Container(
width: 88,
color: const Color(0xff5D4A99),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: const [
Text(
"\$25",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
color: Colors.white,
),
),
Text.rich(
TextSpan(
style: TextStyle(),
children: [
TextSpan(
text: 'TODAY\n',
style: TextStyle(
color: Colors.grey,
fontSize: 10,
),
),
TextSpan(
text: '06:30 PM',
style: TextStyle(
color: Colors.white,
fontSize: 13,
),
),
],
),
),
],
),
),
Expanded(
child: Container(
color: Colors.white,
height: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 10),
Image.asset("assets/images/dots.png", width: 16, height: 42),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'W 90th St, New York, 10024',
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
// 半个像素的分隔线
Container(
margin: const EdgeInsets.fromLTRB(0, 8, 10, 8),
width: double.infinity,
height: 0.5,
color: Colors.grey[200],
),
const Text(
'46th Ave, Woodside, 10011',
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
],
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
explainText('REQUEST', '6'),
explainText('PLEDGE', '\$150'),
explainText('WEIGHT', 'Light'),
],
)
],
),
),
)
],
),
),
);
}

组件2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Widget component2() {
return Container(
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
height: 44,
color: const Color(0xff5D4A99),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Icon(Icons.menu, color: Colors.white),
Text(
"# 2618-3157",
style: TextStyle(fontSize: 16, color: Colors.white),
),
Text(
"\$25",
style: TextStyle(fontSize: 16, color: Colors.white),
),
],
),
),
Stack(
children: [
Image.asset("assets/images/image.png", width: double.infinity, height: 121, fit: BoxFit.cover),
Positioned.fill(
bottom: 12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
explainText('REQUEST', '6', subTitleColor: Colors.white),
explainText('PLEDGE', '\$150', subTitleColor: Colors.white),
explainText('WEIGHT', 'light', subTitleColor: Colors.white),
],
),
)
],
)
],
),
);
}

组件3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
Widget component3() {
return Container(
color: Colors.white,
padding: const EdgeInsets.fromLTRB(10, 10, 10, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: Text(
'SENDER',
style: TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
),
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Image.asset('assets/images/avatar.png', width: 48, height: 48),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Edward Norton',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.black87,
),
),
const SizedBox(height: 4),
Row(
children: [
RatingBar.builder(
initialRating: 3,
minRating: 1,
itemSize: 14,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemPadding: const EdgeInsets.symmetric(horizontal: 0),
itemBuilder: (ctx, _) => const Icon(
Icons.star,
color: Colors.amber,
),
onRatingUpdate: (double value) {},
),
const SizedBox(width: 4),
const Text(
'(26)',
style: TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
),
],
),
const Spacer(),
const Icon(
Icons.arrow_forward_ios,
size: 16,
color: Colors.grey,
),
],
),
Divider(
color: Colors.grey[300],
),
],
),
);
}

组件4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Widget component4() {
return Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: Row(
children: [
Expanded(
child: multipleLineText('FROM', 'W 90th St', 'New York, NY 10025'),
),
Expanded(
child: multipleLineText('TO', '46th Ave', 'Woodside, NY 11101'),
),
const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey),
],
),
);
}

组件5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Widget component5() {
return Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: Row(
children: [
Expanded(
child: multipleLineText('DELIVERY', '06:30 pm', 'May 16, 2021'),
),
Expanded(
child: multipleLineText('REQUEST DEADLINE', '24 minutes', ''),
),
const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey),
],
),
);
}

组件6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Widget component6() {
return ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(8),
),
child: Container(
color: Colors.white,
padding: const EdgeInsets.all(10),
child: Column(
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
primary: const Color(0xffFeBE16),
),
onPressed: () {},
child: const SizedBox(
height: 36,
child: Center(
child: Text(
'SENDER REQUEST',
style: TextStyle(
fontSize: 15,
color: Colors.black87,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 4),
const Text(
'5 people have sent a request',
style: TextStyle(
color: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}

关键折叠动画组件

FoldingComponent

FoldingComponent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class FoldingComponent extends AnimatedWidget {
final Animation<double> animation;

/// 每个组件有正反两面的 widget
/// 正面的 widget
late final Widget? frontChild;

/// 背面的 widget
late final Widget? backChild;

/// 是否在展示正面的 widget
final bool isFrontShowing;

/// 180 度
final double anglePI = 180.0;

/// 90 度
final double angleHalfPI = 90.0;

/// 是否有背面的 widget
bool get hasBackChild => backChild != null;

FoldingComponent({
required this.animation,
this.frontChild,
this.isFrontShowing = false,
this.backChild,
}) : super(listenable: animation);

@override
Widget build(BuildContext context) {
/// 对齐方式
Alignment alignment = hasBackChild ? Alignment.bottomCenter : Alignment.topCenter;

/// 旋转角度
double rotateAngle = (pi / anglePI) * (hasBackChild ? animation.value : -animation.value);

/// 视图底部的圆角
BorderRadius bottomRadius = BorderRadius.vertical(
bottom: Radius.circular(8 * ((animation.value) == 0 ? 0 : 1)),
);

/// 背景颜色
Color backgroundColor =
(isFrontShowing && animation.value < angleHalfPI) || (!isFrontShowing && animation.value == 0.0) ? Colors.transparent : Colors.white.withOpacity(0.8);

/// 当前需要展示的部件
Widget? showingChild = (isFrontShowing && animation.value < angleHalfPI) || (animation.value == 0.0 && !hasBackChild) ? frontChild : backChild;

return animation.value == anglePI && !hasBackChild
? const SizedBox.shrink()

/// 翻转到背面,但是不存在背面 widget
: Transform(
alignment: alignment,

/// 3d 动画的矩阵设置
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)

/// 沿着 X 轴做旋转动画
..rotateX(rotateAngle),
child: Container(
color: backgroundColor,
child: showingChild,
),
);
}
}

FoldingCell

FoldingCell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
enum FoldingState {
open,
close,
}

class FoldingCell extends StatefulWidget {
/// 当卡片关闭或打开时的回调
final ValueChanged<FoldingState> onChanged;

/// 卡片的预设状态
final FoldingState foldingState;

const FoldingCell({
Key? key,
required this.onChanged,
this.foldingState = FoldingState.close,
}) : super(key: key);

@override
State<FoldingCell> createState() => _FoldingCellState();
}

class _FoldingCellState extends State<FoldingCell> with SingleTickerProviderStateMixin {
/// 动画控制器
late AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 360),
);

if (widget.foldingState == FoldingState.close) {
_controller.forward(from: 1.0);
} else {
_controller.reverse(from: 0);
}

_controller.addStatusListener((status) {
if (status == AnimationStatus.forward) {
/// 执行了关闭动画
widget.onChanged(FoldingState.close);
}
if (status == AnimationStatus.reverse) {
/// 执行了打开动画
widget.onChanged(FoldingState.open);
}
});
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

/// 折叠或打开
void toggle() {
if (_controller.value == 1) {
_controller.reverse();
} else {
_controller.forward();
}
}

/// 根据开始、结束角度 + 动画过程间隔生成 animation
Animation<double> generateAnimation({
/// 旋转的开始角度
beginAngle: double,

/// 旋转的结束角度

endAngle: double,

/// 改段动画的起点
intervalBegin: double,

/// 改段动画的结束
intervalEnd: double,
}) {
return Tween<double>(
begin: beginAngle,
end: endAngle,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
intervalBegin,
intervalEnd,
),
),
);
}

@override
Widget build(BuildContext context) {
double padding = 8.0;
double cardHeight = 165.0;
double totalHeight = 501.0;

return GestureDetector(
onTap: () {
toggle();
},
child: AnimatedBuilder(
/// 使用AnimatedBuilder是为了在 listView 中动态改变高度时做动画
animation: _controller,
builder: (BuildContext context, Widget? child) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: padding * 2,
vertical: padding,
),

/// 计算高度的原理:
/// 开始时只显示卡片的一部分,后面随着动画过程增加 Cell 高度
height: cardHeight + padding * 2 + (totalHeight - cardHeight) * (1.0 - _controller.value),
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: Column(
children: [
Container(
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(padding),

/// 底部圆角随动画改变
bottom: Radius.circular(8 * _controller.value),
),
child: Container(
height: cardHeight,
child: component2(),
),
),
Container(
height: cardHeight,
child: FoldingComponent(
backChild: component1(),
animation: generateAnimation(
beginAngle: 90.0,
endAngle: 0.0,
intervalBegin: 0.75,
intervalEnd: 1.0,
),
),
),
],
),
),
Container(
height: 94,
child: FoldingComponent(
frontChild: component3(),
animation: generateAnimation(
beginAngle: 0.0,
endAngle: 90.0,
intervalBegin: 0.5,
intervalEnd: 0.75,
),
),
),
Container(
height: 72,
child: FoldingComponent(
isFrontShowing: true,
frontChild: Container(
child: component4(),
),
animation: generateAnimation(
beginAngle: 0.0,
endAngle: 180.0,
intervalBegin: 0.3,
intervalEnd: 0.5,
),
),
),
Container(
height: 72,
child: FoldingComponent(
isFrontShowing: true,
frontChild: Container(
child: component5(),
),
animation: generateAnimation(
beginAngle: 0.0,
endAngle: 180.0,
intervalBegin: 0.2,
intervalEnd: 0.3,
),
),
),
Container(
height: 86,
child: FoldingComponent(
isFrontShowing: true,
frontChild: Container(
child: component6(),
),
animation: generateAnimation(
beginAngle: 0.0,
endAngle: 180.0,
intervalBegin: 0.0,
intervalEnd: 0.2,
),
),
),
],
),
)
],
),
);
},
),
);
}
}

页面实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import 'package:flutter/material.dart';
import 'package:folding_cell/folding_cell.dart';

class FoldingCellListPage extends StatefulWidget {
const FoldingCellListPage({Key? key}) : super(key: key);

@override
State<FoldingCellListPage> createState() => _FoldingCellListPageState();
}

class _FoldingCellListPageState extends State<FoldingCellListPage> {
/// 记录哪些索引是打开了的
/// 这里用 Set 是因为我们并不关心它们打开的顺序
late Set<int> openedIndices = {};

@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
Image.asset(
'assets/images/background.png',
width: MediaQuery.of(context).size.width,
fit: BoxFit.cover,
),
Center(
child: ListView.builder(
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return FoldingCell(
key: ValueKey(index),
foldingState: openedIndices.contains(index) ? FoldingState.open : FoldingState.close,
onChanged: (foldState) {
if (foldState == FoldingState.open) {
print('打开了 cell -- $index');
openedIndices.add(index);
} else {
print('关闭了 cell -- $index');
openedIndices.remove(index);
}
},
);
}),
)
],
),
);
}
}

动画还没系统学习,到后面就看不懂了,这里先跟着敲了一遍代码。:(