在移动应用中,用户互动性是一个关键的体验要素。Flutter,作为一个强大且灵活的 UI 工具箱,提供了一系列的工具来增强这种互动体验。

我在做 app 的时候, 想给一个卡片设置一个拖动效果, 这样用户就可以把自己的页面中的便笺随意拖动到自己喜欢的位置. 浏览文档发现Flutter 提供了一个 Draggable的组件. 下面就让我们一起先看一下这个组件吧

Draggable

基础:什么是Draggable?
Draggable是Flutter中一个内置的widget,它可以让任何子组件变得可拖拽。这为构建如列表排序、拖放交互等复杂功能提供了基础。

基础用法非常简单:

1
2
3
4
5
6
Draggable(
child: Icon(Icons.star), // 正常显示的widget
feedback: Icon(Icons.star, color: Colors.grey), // 拖动时的widget
data: "Star Data", // 被拖拽的数据
)

Draggable的核心属性
child: 这是用户看到的widget,当它未被拖动时。

feedback: 当用户开始拖动widget时,会显示的widget。通常,这应该是child的一个轻微变种,例如可能的颜色更改或大小更改。

data: 当项目被拖放时,你可能想传递某些数据。这可以是任何类型的数据。

和DragTarget一起使用
仅仅有Draggable是不够的,我们还需要一个目标来放置我们的数据,这就是DragTarget的作用。

1
2
3
4
5
6
7
8
9
10
11
DragTarget<String>(
builder: (context, acceptedItems, rejectedItems) {
return Container(
height: 50,
color: acceptedItems.isEmpty ? Colors.blue : Colors.red,
);
},
onWillAccept: (data) => data == "Star Data",
onAccept: (data) => print("Accepted!"),
)

onWillAccept: 它将检查放置的数据是否可被接受。

onAccept: 当Draggable的数据被接受并放置在DragTarget上时,该函数将被调用。

进阶:使用Draggable的更多属性
除了基本的功能,Draggable还有一些其他属性,如:

axis: 如果你只想让widget在特定的轴上可拖动(例如仅垂直或水平),你可以使用此属性。

affinity: 对于多点触控,它决定了拖拽是否应该与首个手指的移动同步。

childWhenDragging: 当child被拖动时,在原来的位置上显示的widget。

下面我们来看一段测试代码:

  1. 开始拖动 “Drag Me” 文本上的蓝色方块。
  2. 继续将其拖动到 “Drop Here!” 文本上的灰色方块上。
  3. 当蓝色方块完全位于灰色方块之上时,释放鼠标或触摸。
  4. 此时,灰色的 DragTarget 方块应变为蓝色。
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
testDragTarget(){
return Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Draggable<Color>(
data: Colors.blue,
child: SizedBox(
width: 100,
height: 100,
child: Material(
color: Colors.blue,
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(20),
),
elevation: 3,
child: Center(
child: Text('Drag Me'),
),
),
),
feedback: SizedBox(
width: 100,
height: 100,
child: Material(
color: Colors.blue.withOpacity(0.5),
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text('Dragging...'),
),
),
),
),
DragTarget<Color>(
builder: (BuildContext context, List<Color?> candidateData, List<dynamic> rejectedData) {
return Container(
width: 150,
height: 150,
color: caughtColor,
child: Center(
child: Text('Drop Here!'),
),
);
},
onWillAccept: (data) => data == Colors.blue,
onAccept: (data) {
setState(() {
caughtColor = data!;
});
},
)
],
);
}


使用 GestureDetector 实现拖拽

如果你不想使用 Draggable 来实现, 那我们可以通过手势监听的操作来实现一个拖拽功能;
GestureDetector是一个非常强大的Flutter组件,可以用来检测多种手势,如点击、双击、拖动等。为了实现拖拽功能,我们需要监听onPanUpdate事件。
从源码中,我们可以看到, GestureDetector支持非常多的监听:

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
// 当手指与屏幕接触并开始形成轻触手势时触发
this.onTapDown,
// 当用户完成轻触手势后(抬起手指)触发
this.onTapUp,
// 当轻触手势成功触发
this.onTap,
// 当轻触手势被中断时触发,例如移动手指太快
this.onTapCancel,
// 当用户完成第二个触摸点的轻触手势时触发
this.onSecondaryTap,
// 当第二个触摸点与屏幕接触并开始形成轻触手势时触发
this.onSecondaryTapDown,
// 当第二个触摸点完成轻触手势后(抬起手指)触发
this.onSecondaryTapUp,
// 当第二个触摸点的轻触手势被中断时触发
this.onSecondaryTapCancel,
// 对于第三个触摸点的轻触手势开始时触发
this.onTertiaryTapDown,
// 对于第三个触摸点的轻触手势结束时触发
this.onTertiaryTapUp,
// 对于第三个触摸点的轻触手势被中断时触发
this.onTertiaryTapCancel,
// 双击手势的第一次点击时触发
this.onDoubleTapDown,
// 双击手势成功触发时
this.onDoubleTap,
// 双击手势被中断时触发
this.onDoubleTapCancel,
// 用户长时间按下屏幕时触发
this.onLongPressDown,
// 用户结束长时间按压手势(在移动之前抬起手指)时触发
this.onLongPressCancel,
// 用户完成长时间按压手势时触发
this.onLongPress,
// 用户在长按手势上开始移动手指时触发
this.onLongPressStart,
// 用户在长按手势上移动手指时触发
this.onLongPressMoveUpdate,
// 用户在长按手势上抬起手指时触发
this.onLongPressUp,
// 用户在长按手势上完成移动时触发
this.onLongPressEnd,
// 第二个触摸点的长按手势开始时触发
this.onSecondaryLongPressDown,
// 第二个触摸点的长按手势被中断时触发
this.onSecondaryLongPressCancel,
// 第二个触摸点的长按手势完成时触发
this.onSecondaryLongPress,
// 第二个触摸点在长按手势上开始移动时触发
this.onSecondaryLongPressStart,
// 第二个触摸点在长按手势上移动时触发
this.onSecondaryLongPressMoveUpdate,
// 第二个触摸点在长按手势上抬起手指时触发
this.onSecondaryLongPressUp,
// 第二个触摸点在长按手势上完成移动时触发
this.onSecondaryLongPressEnd,
// 第三个触摸点的长按手势开始时触发
this.onTertiaryLongPressDown,
// 第三个触摸点的长按手势被中断时触发
this.onTertiaryLongPressCancel,
// 第三个触摸点的长按手势完成时触发
this.onTertiaryLongPress,
// 第三个触摸点在长按手势上开始移动时触发
this.onTertiaryLongPressStart,
// 第三个触摸点在长按手势上移动时触发
this.onTertiaryLongPressMoveUpdate,
// 第三个触摸点在长按手势上抬起手指时触发
this.onTertiaryLongPressUp,
// 第三个触摸点在长按手势上完成移动时触发
this.onTertiaryLongPressEnd,
// 用户开始垂直拖动时触发
this.onVerticalDragDown,
// 用户确定开始垂直拖动手势时触发(例如手指开始移动)
this.onVerticalDragStart,
// 用户在垂直方向上拖动手指时触发
this.onVerticalDragUpdate,
// 用户结束垂直拖动手势时触发
this.onVerticalDragEnd,
// 垂直拖动手势被中断时触发
this.onVerticalDragCancel,
// 用户开始水平拖动时触发
this.onHorizontalDragDown,
// 用户确定开始水平拖动手势时触发(例如手指开始移动)
this.onHorizontalDragStart,
// 用户在水平方向上拖动手指时触发
this.onHorizontalDragUpdate,
// 用户结束水平拖动手势时触发
this.onHorizontalDragEnd,
// 水平拖动手势被中断时触发
this.onHorizontalDragCancel,
// 用户开始强按(3D Touch或Force Touch)手势时触发
this.onForcePressStart,
// 用户在强按手势达到峰值压力时触发
this.onForcePressPeak,
// 用户在强按手势过程中更新压力值时触发
this.onForcePressUpdate,
// 用户结束强按手势时触发
this.onForcePressEnd,
// 用户开始拖动(无论方向)时触发,这通常用于当方向尚未确定时
this.onPanDown,
// 用户确定开始拖动手势时触发
this.onPanStart,
// 用户拖动手指时触发
this.onPanUpdate,
// 用户结束拖动手势时触发
this.onPanEnd,
// 拖动手势被中断时触发
this.onPanCancel,
// 用户开始缩放手势时触发
this.onScaleStart,
// 用户在缩放或旋转手势过程中更新触摸点或旋转角度时触发
this.onScaleUpdate,
// 用户结束缩放手势时触发
this.onScaleEnd,

我们可以通过监听 onPanUpdate, 来监听移动的坐标,并通过移动更新后的坐标来更新 widget 的位置信息;

  1. 当用户开始拖动蓝色方块时,onPanUpdate事件会被触发。
  2. details.delta.dx和details.delta.dy表示自上一次更新以来水平和垂直方向的移动距离。通过更新top和left值,我们可以移动Positioned小部件的位置,从而实现拖拽效果。
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
  double top = 0;
double left = 0;
testGestureDetector(){
return Stack(
children: <Widget>[
Positioned(
top: top,
left: left,
child: GestureDetector(
onTap: () {
print("点击了蓝色方块");
},
onPanUpdate: (details) {
setState(() {
left += details.delta.dx;
top += details.delta.dy;
});
},
child: Container(
color: Colors.blue,
width: 50,
height: 50,
),
),
),


],
);
}

当然了, 我们今天的主题是实现两个小动画. 上面之所以提到拖拽, 是因为我在写卡片的时候需要添加一个拖拽功能. 了解一下相关的技术实现. 下面我们就进入正题

自定义一个旋转动画, 支持自定义动画曲线

Flutter 为我们提供了AnimationController、Tween 以及定义好的Curve 来实现我们的动画效果。但是, 有时候我们可能自己实现Curve, 来实现一些动画曲线, 下面就让我们一起看一看吧
AnimationController、Tween和Curve是Flutter 动画中最常被提及的三个核心概念;它们共同作用,为我们创建丰富的动画效果.

AnimationController:

AnimationController是动画的一个控制器,用于控制动画的执行。它可以产生从0到1的线性输出,表示动画的当前进度。

  • 它是一个特殊的Animation对象,输出一个0.0到1.0之间的值。
  • 你可以使用它来启动和停止动画,反向播放动画,或者设置动画的持续时间。
  • 它还允许你指定动画的duration(持续时间)和vsync(用于防止屏幕之外的动画消耗不必要的资源)。
  • 使用.forward()启动动画,使用.reverse()反向播放,使用.stop()停止,等等。
1
2
3
4
AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);

Tween:

Tween代表两个值之间的线性插值。例如,一个Tween可以有一个开始值(begin)和一个结束值(end),如从0.0到255.0。

  • 它可以将AnimationController的输出(通常是0.0到1.0之间的值)映射到指定的范围,从而为我们提供所需的值。例如,将0.0-1.0的值映射到0.0-255.0的值。
  • Tween对象本身并不存储任何状态,只是定义了转换值的方式。
1
2
Tween<double>(begin: 0.0, end: 255.0);

Curve:

动画不总是线性的。有时我们希望它有一个特定的速率曲线,如先快后慢,或者反之。Curve定义了这样的速率曲线。
Flutter内置了多种曲线,如Curves.easeIn、Curves.bounceOut等。
Curve可以与Tween结合使用,为动画值提供非线性变化。

1
CurvedAnimation(parent: animationController, curve: Curves.easeIn);

结合这三者,我们可以创建各种复杂度的动画。首先,使用AnimationController来定义和控制动画的生命周期;然后使用Tween来定义动画的值范围;最后,使用Curve给动画加上非线性的速率曲线,使动画更加生动有趣。

下面我们看一段实际代码示例:

我们使用 stack + gestureDetector 实现了一个可拖拽组件, 并且点击组件后组件会做一个镜像旋转.

我们使用了 WindBlownCurve 来模拟一段自定的动画曲线, 并将将动画分为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
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

class CardAnim extends StatefulWidget {
@override
_CardAnimState createState() => _CardAnimState();
}

class _CardAnimState extends State<CardAnim>
with SingleTickerProviderStateMixin {
double top = 0;
double left = 0;
late AnimationController _rotationController;
late Animation<double> _rotationAnimation;

@override
void initState() {
super.initState();

_rotationController = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
);

_rotationAnimation = Tween<double>(begin: 0, end: 3.14159265).animate(
CurvedAnimation(parent: _rotationController, curve: WindBlownCurve()))
..addListener(() {
setState(() {});
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: testGestureDetector(),
);
}

testGestureDetector(){
return Stack(
children: <Widget>[
Positioned(
top: top,
left: left,
child: GestureDetector(
onTap: () {
print("点击了蓝色方块");
if (_rotationController!.isAnimating) {
_rotationController!.stop();
} else {
_rotationController!.forward(from: 0);
}
},
onPanUpdate: (details) {
setState(() {
left += details.delta.dx;
top += details.delta.dy;
});
},
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001) // perspective
..rotateY(_rotationAnimation!.value),
child: Container(
color: Colors.blue,
width: 50,
height: 50,
),
),
),
),


],
);
}

Color caughtColor = Colors.grey;
testDragTarget(){
return Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Draggable<Color>(
data: Colors.blue,
child: SizedBox(
width: 100,
height: 100,
child: Material(
color: Colors.blue,
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(20),
),
elevation: 3,
child: Center(
child: Text('Drag Me'),
),
),
),
feedback: SizedBox(
width: 100,
height: 100,
child: Material(
color: Colors.blue.withOpacity(0.5),
shape: RoundedRectangleBorder(
side: BorderSide.none,
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text('Dragging...'),
),
),
),
),
DragTarget<Color>(
builder: (BuildContext context, List<Color?> candidateData, List<dynamic> rejectedData) {
return Container(
width: 150,
height: 150,
color: caughtColor,
child: Center(
child: Text('Drop Here!'),
),
);
},
onWillAccept: (data) => data == Colors.blue,
onAccept: (data) {
setState(() {
caughtColor = data!;
});
},
)
],
);
}

@override
void dispose() {
_rotationController!.dispose();
super.dispose();
}
}
class WindBlownCurve extends Curve {
@override
double transform(double t) {
if (t < 0.5 / 3) {
// 0 - 0.5s: 范围从 0 到 0.1,用二次函数模拟慢速增长
return 0.2 * t / (0.5 / 3);
} else if (t < 2.5 / 3) {
// 0.5 - 2.5s: 范围从 0.1 到 0.9,线性增长
return 0.2 + 0.6 * (t - (0.5 / 3)) / (2 / 3);
} else {
// 2.5 - 3s: 范围从 0.9 到 1,用二次函数模拟慢速增长
return 0.8 + 0.2 * (t - (2.5 / 3)) / (0.5 / 3);
}
}
}

小球掉落动画

这个动画来自之前我做过的一个 app 的初次加载动画. 模拟鸡蛋掉落后, 蛋壳破碎, 从蛋壳飞出来一个 app 的 logo;
我做了简化, 把掉落动画单独拿出来了,

主要功能:

  • 显示一个可以自由落体的蓝色小球。
  • 当小球落到屏幕底部时,会发生反弹。
  • 当反弹速度很小(几乎停止时),动画将会结束。

实现思路:

  • 初始化:

    • 使用SingleTickerProviderStateMixin为动画提供Ticker。
    • 定义了四个变量:ballY (小球的垂直位置)、ballVelocity (小球的速度)、gravity (重力加速度)和bounceFactor (小球碰到屏幕底部后的反弹系数)。
  • 动画控制:

    • 创建一个AnimationController,持续时间为5秒。
    • 为AnimationController添加一个监听器。每当动画产生一个新的值时,这个监听器就会执行。
  • 小球的动态:

    • 每次监听器被调用,都会根据重力更新小球的速度,然后用这个速度来更新小球的位置。
    • 判断小球是否碰到了屏幕的底部(考虑到小球的大小)。如果是,则让小球的位置回到底部边缘,并且根据bounceFactor来调整速度,从而实现反弹效果。
    • 如果小球速度几乎为零并且位于屏幕底部,那么停止动画。否则,继续动画。

代码如下:

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
import 'package:flutter/material.dart';

class BallDropScreen extends StatefulWidget {
@override
_BallDropScreenState createState() => _BallDropScreenState();
}

class _BallDropScreenState extends State<BallDropScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double ballY = 0; // 球的垂直位置
double ballVelocity = 0; // 球的速度
double gravity = 0.5; // 重力
double bounceFactor = -0.7; // 反弹系数

@override
void initState() {
super.initState();

_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 5),
);

_controller.addListener(() {
double width = MediaQuery.of(context).size.width;
double height = MediaQuery.of(context).size.height;
double screenHeight = height - 80;
double ballSize = 50; // 小球的大小

ballVelocity += gravity; // 加速度
ballY += ballVelocity;

// 如果球碰到屏幕底部
if (ballY + ballSize > screenHeight) {
ballY = screenHeight - ballSize;
ballVelocity *= bounceFactor; // 反弹
}

setState(() {});

if (ballY + ballSize >= screenHeight && ballVelocity.abs() < 1) {
_controller.stop();
} else {
_controller.forward();
}
});

_controller.forward();
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
Positioned(
left: 50,
top: ballY,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
),
),
),
],
),
);
}

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

本文的代码你可以在这里看到
小球动画
拖拽组件和旋转动画