注册

Flutter实现掘金App点赞效果

前言


点赞这个动作不得不说在社交、短视频等App中实在是太常见了,当用户手指按下去的那一刻,给用户一个好的反馈效果也是非常重要的,这样用户点起赞来才会有一种强烈的我点了赞的效果,那么今天我们就用Flutter实现一个掘金App上的点赞效果。



  • 首先我们看下掘金App的点赞组成部分,有一个小手,点赞数字、点赞气泡效果,还有一个震动反馈,接下来我们一步一步实现。

知识点:绘制、动画、震动反馈


1、绘制小手


这里我们使用Flutter的Icon图标中的点赞小手,Icons图标库为我们提供了很多App常见的小图标,如果使用苹果苹果风格的小图标可以使用cupertino_icons: ^1.0.2插件,图标并不是图片,本质上和emoji图标一样,可以添加到文本中使用,所以图标才可以设置不同的颜色属性,对比使用png格式图标可以节省不少的内存。
image.png
接下来我们就将这两个图标绘制出来,首先我们从上图可以看到真正的图标数据其实是IconData类,里面有一个codePoint属性可以获取到Unicode统一码,通过String.fromCharCode(int charCode)可以返回一个代码单元,在Text文本中支持显示。


class IconData{
/// The Unicode code point at which this icon is stored in the icon font.
/// 获取此图标的Unicode代码点
final int codePoint;
}

class String{
/// 如果[charCode]可以用一个UTF-16编码单元表示,则新的字符串包含一个代码单元
external factory String.fromCharCode(int charCode);
}

接下来我们就可以把图标以绘制文本的形式绘制出来了

关键代码:


  // 赞图标
final icon = Icons.thumb_up_alt_outlined;
// 通过TextPainter可以获取图标的尺寸
TextPainter textPainter = TextPainter(
text: TextSpan(
text: String.fromCharCode(icon.codePoint),
style: TextStyle(
fontSize: 30,
fontFamily: icon.fontFamily,// 字体形象家族,这个字段一定要设置,不然显示不出来
color: Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter.layout(); // 进行布局
Size size2 = textPainter.size; // 尺寸必须在布局后获取
//将图标偏移到画布中央
textPainter.paint(canvas, Offset(-size2.width / 2, -size2.height / 2));

通过上方代码我们就实现了将图标绘制到画板当中

image.png

接下来继续绘制点赞数量,

代码:


TextPainter textPainter2 = TextPainter(
text: TextSpan(
text: "点赞",// 点赞数量
style: TextStyle(
fontSize: 9, fontWeight: FontWeight.w500, color: Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter2.layout(); // 进行布局
// 向右上进行偏移在小手上面
textPainter2.paint(canvas, Offset(size.width / 9, -size.height / 2 + 5));

然后图标就变成了这样样子,

image.png

我们看到,掘金App点赞的过程中,周围还有一些小气泡的效果,这里提供一个思路,将这些气泡的坐标点放到一个圆的外环上面,通过动画改变圆的半径达到小圆点由内向外发散,发散的同时改变小圆点的大小,从而达到气泡的效果,
关键代码:


var r = size.width / 2 - 15; // 半径
var d = 4; // 偏移量 气泡的移动距离

// 绘制小圆点 一共4个 掘金也是4个 角度可以自由发挥 这里根据掘金App的发散角度定义的
canvas.drawPoints(
ui.PointMode.points,
[
Offset((r + d * animation2.value) * cos(pi - pi / 18 * 2),
(r + d * animation2.value) * sin(pi - pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi + pi / 18 * 2),
(r + d * animation2.value) * sin(pi + pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi * 1.5 - pi / 18),
(r + d * animation2.value) * sin(pi * 1.5 - pi / 18)),
Offset((r + d * animation2.value) * cos(pi * 1.5 + pi / 18 * 5),
(r + d * animation2.value) * sin(pi * 1.5 + pi / 18 * 5)),
],

_paint
..strokeWidth = 5
..color = Colors.blue
..strokeCap = StrokeCap.round);

得到现在的图形,
发散前

image.png

发散后

image.png

接下来继续我们来添加交互效果,添加动画,如果有看上一篇吃豆人,相信这里就很so easy了,首先创建两个动画类,控制小手和气泡,再创建两个变量,是否点赞和点赞数量,代码:


late Animation<double> animation; // 赞
late Animation<double> animation2; // 小圆点
ValueNotifier<bool> isZan = ValueNotifier(false); // 记录点赞状态 默认没点赞
ValueNotifier<int> zanNum = ValueNotifier(0); // 记录点赞数量 默认0点赞

这里我们需要使用动画曲线CurvedAnimation这个类,这个类可以实现不同的0-1的运动曲线,根据掘金的点赞效果,比较符合这个曲线规则,快速放大,然后回归正常大小,这个类帮我们实现了很多好玩的运动曲线,有兴趣的小伙伴可以尝试下其他运动曲线。

小手运动曲线

f299df8cd653f6f24de8553bebf055d2.gif

气泡运动曲线:

2222.gif

有了运动曲线之后,接下来我们只需将属性赋值给小手手和小圆点就好了,

完整源码: 封装一下,对外暴露大小,就是一个点赞组件了。


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

@override
_ZanDemoState createState() => _ZanDemoState();
}

class _ZanDemoState extends State<ZanDemo> with TickerProviderStateMixin {
late Animation<double> animation; // 赞
late Animation<double> animation2; // 小圆点
ValueNotifier<bool> isZan = ValueNotifier(false); // 记录点赞状态 默认没点赞
ValueNotifier<int> zanNum = ValueNotifier(0); // 记录点赞数量 默认0点赞

late AnimationController _controller; // 控制器
late AnimationController _controller2; // 小圆点控制器
late CurvedAnimation cure; // 动画运行的速度轨迹 速度的变化
late CurvedAnimation cure2; // 动画运行的速度轨迹 速度的变化

int time = 0;// 防止快速点两次赞导致取消赞

@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 500)); //500ms
_controller2 = AnimationController(
vsync: this, duration: const Duration(milliseconds: 800)); //500ms

cure = CurvedAnimation(parent: _controller, curve: Curves.easeInOutBack);
cure2 = CurvedAnimation(parent: _controller2, curve: Curves.easeOutQuint);
animation = Tween(begin: 0.0, end: 1.0).animate(cure);
animation2 = Tween(begin: 0.0, end: 1.0).animate(_controller2);
}

@override
Widget build(BuildContext context) {
return InkWell(
child: Center(
child: CustomPaint(
size: Size(50, 50),
painter: _ZanPainter(animation, animation2, isZan, zanNum,
Listenable.merge([animation, animation2, isZan, zanNum])),
),
),
onTap: () {
if (!isZan.value && !_isDoubleClick()) {
_controller.forward(from: 0);
// 延迟300ms弹窗气泡
Timer(Duration(milliseconds: 300), () {
isZan.value = true;
_controller2.forward(from: 0);
});
Vibrate.feedback(FeedbackType.success);
zanNum.value++;
} else if (isZan.value) {
Vibrate.feedback(FeedbackType.success);
isZan.value = false;
zanNum.value--;
}
},
);
}

bool _isDoubleClick() {
if (time == 0) {
time = DateTime.now().microsecondsSinceEpoch;
return false;
} else {
if (DateTime.now().microsecondsSinceEpoch - time < 800 * 1000) {
return true;
} else {
time = DateTime.now().microsecondsSinceEpoch;
return false;
}
}
}
}

class _ZanPainter extends CustomPainter {
Animation<double> animation;
Animation<double> animation2;
ValueNotifier<bool> isZan;
ValueNotifier<int> zanNum;
Listenable listenable;

_ZanPainter(
this.animation, this.animation2, this.isZan, this.zanNum, this.listenable)
: super(repaint: listenable);

Paint _paint = Paint()..color = Colors.blue;
List<Offset> points = [];

@override
void paint(Canvas canvas, Size size) {
canvas.clipRect(Offset.zero & size);
canvas.translate(size.width / 2, size.height / 2);
// 赞
final icon =
isZan.value ? Icons.thumb_up_alt_rounded : Icons.thumb_up_alt_outlined;
// 通过TextPainter可以获取图标的尺寸
TextPainter textPainter = TextPainter(
text: TextSpan(
text: String.fromCharCode(icon.codePoint),
style: TextStyle(
fontSize: animation.value < 0 ? 0 : animation.value * 30,
fontFamily: icon.fontFamily,
color: isZan.value ? Colors.blue : Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter.layout(); // 进行布局
Size size2 = textPainter.size; // 尺寸必须在布局后获取
//将图标偏移到画布中央
textPainter.paint(canvas, Offset(-size2.width / 2, -size2.height / 2));

var r = size.width / 2 - 15; // 半径
var d = 4; // 偏移量

canvas.drawPoints(
ui.PointMode.points,
[
Offset((r + d * animation2.value) * cos(pi - pi / 18 * 2),
(r + d * animation2.value) * sin(pi - pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi + pi / 18 * 2),
(r + d * animation2.value) * sin(pi + pi / 18 * 2)),
Offset((r + d * animation2.value) * cos(pi * 1.5 - pi / 18 * 1),
(r + d * animation2.value) * sin(pi * 1.5 - pi / 18 * 1)),
Offset((r + d * animation2.value) * cos(pi * 1.5 + pi / 18 * 5),
(r + d * animation2.value) * sin(pi * 1.5 + pi / 18 * 5)),
],
_paint
..strokeWidth = animation2.value <= 0.5 ? (5 * animation2.value) / 0.5
: 5 * (1 - animation2.value) / 0.5
..color = Colors.blue
..strokeCap = StrokeCap.round);
TextPainter textPainter2 = TextPainter(
text: TextSpan(
text: zanNum.value == 0 ? "点赞" : zanNum.value.toString(),
style: TextStyle(
fontSize: 9, fontWeight: FontWeight.w500, color: Colors.black)),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr);
textPainter2.layout(); // 进行布局
// 向右上进行偏移在小手上面
textPainter2.paint(canvas, Offset(size.width / 9, -size.height / 2 + 5));
}

@override
bool shouldRepaint(covariant _ZanPainter oldDelegate) {
return oldDelegate.listenable != listenable;
}
}

到这里发现是不是少了点什么,不错,还少了震动的效果,这里我们引入flutter_vibrate: ^1.3.0这个插件,这个插件是用来管理设备震动效果的,Andoroid端记得加入震动权限,

<uses-permission android:name="android.permission.VIBRATE"/>

使用方法也很简单,这个插件封装了一些常见的提示震动,比如操作成功、操作警告、操作失败等,其实就是震动时间的长短,这里我们就在点赞时候调用Vibrate.feedback(FeedbackType.success);有一个点击成功的震动就好了。



作者:老李code
链接:https://juejin.cn/post/7088960905429385253
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册