本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。
经常逛 Github 的同学可能发现了,GitHub 的加载列表多了个 章鱼猫 ,就是下图那个动一动的玩意儿,比先前单调的菊花转不知要好多少倍。所以我来整活了啦:用 flutter来实现一个和下图一毛一样的动画。为单调的加载加上,一丝可爱,一丝俏皮,一丝炫酷 ...?觉得棒棒的同学,请不要吝啬您的 点赞
、 评论
、 关注
、 收藏
、 分享
,您的支持是我码字的动力,万分感谢!!!🌈
开始分析
要实现一个上面动图的动画效果,不就直接用 Image 组件加载一下上面的gif图就实现了吗?是的,没错,确实 👊,本篇文章到此结束,谢谢大家的观看。🙌
嘻嘻,我怎么可能这么肤浅呢,要弄出这么一个动画,当然得是纯纯的Flutter代码来实现咯 😇。GIF 的缺点我就不多说了,懂的都懂,下面让我正式开始吧
我们先观察一下这个图,很明显,这只 章鱼猫 由一个个 方块像素点 组成,每次游动是不同的像素点之间变化。对于 GIF 来说,那就是一帧帧的图在切换。如果我们要用flutter绘制这个 章鱼猫 ,那么我们需要知道🤔
- 章鱼猫 游起来有多少帧,也就是有多少不同的图
- 每一帧图 章鱼猫 每一个像素点所在的位置以及位置的颜色
如果我们知道上面的这两个关键要素,那绘制这个 章鱼猫 将不再是啥难事儿🤩,用上我们的老朋友 CustomPaint
,将分分钟搞定
如何实现
我们先创建一个 flutter 项目 octocat_loading_anim
,删除一些不必要的代码,整一个干净的代码环境。(ps:创建 flutter项目的过程,不懂的小伙伴可以参考我先前的文章 从零开始的五星红旗 )
然后我们创建一个 assets
目录,将这个 章鱼猫 的 GIF 文件拷贝进去,后面我们在项目中需要用到这个 GIF 图。接着我们把这个 GIF 图在 pubspec.yml
中注册一下,好让flutter能够读取到这个文件。
flutter:
uses-material-design: true
assets:
- assets/mona-loading-default.gif
我们先直接用 Image
组件加载一下 GIF 文件,跑起来看看效果
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Image.asset("assets/mona-loading-default.gif"),
),
);
}
}
运行的很完美,我们已经可以在应用中看到 章鱼猫 游起来了。下面我们正式开始 🤣
如何拿到 GIF 图的每一帧
查看过 ImageProvider 源码的同学肯定了解,在 ImageProvider 加载图片的时候,会用到 dart:ui
包下的 instantiateImageCodec
方法,这个方法会将支持的图片解成 dart:ui
包下的 Codec
对象,这里面就包含了图片的所有信息,这个方法需要一个 Uint8List
的数组。下面我们实际操练一下,拿到 Codec
- 我们先定义一个
_decodeGif
的方法,我们不知道要返回啥,就先写个Future<void>
占着位置
Future<void> _decodeGif() async {}
- 然后从我们拷贝的 GIF 文件获得
Uint8List
,这部分都是基础知识,没什么好讲的
Future<void> _decodeGif() async {
final ByteData byteData = await rootBundle.load("assets/mona-loading-default.gif");
final Uint8List uint8list = byteData.buffer.asUint8List();
}
- 下面我们导入
dart:ui
包,对GIF
的Uint8List
数据进行解码,得到Codec
Future<void> _decodeGif() async {
final ByteData byteData = await rootBundle.load("assets/mona-loading-default.gif");
final Uint8List uint8list = Uint8List.view(byteData.buffer);
final ui.Codec codec = await ui.instantiateImageCodec(uint8list);
}
我们来看看 Codec
里面有些啥
喏喏喏,frameCount
就是我们要的帧数,打印一下。我们现在已经拿到了第一个条件:章鱼猫 有 7 帧的图
Future<void> _decodeGif() async {
final ByteData byteData =
await rootBundle.load("assets/mona-loading-default.gif");
final Uint8List uint8list = Uint8List.view(byteData.buffer);
final ui.Codec codec = await ui.instantiateImageCodec(uint8list);
print("frameCount: ${codec.frameCount}");
}
如何获取每一帧图需要绘制像素点位置
上一个问题中,我们已经拿到了 Codec
, 在 Codec
中有个 getNextFrame
方法,这个方法我们可以拿到每一帧的详细内容。 包括图片的信息 image
,以及每帧的时间
Future<void> _decodeGif() async {
// ...
for (int i = 0; i < codec.frameCount; i++) {
final ui.FrameInfo frame = await codec.getNextFrame();
final Duration duration = frame.duration;
final int width = frame.image.width;
final int height = frame.image.height;
print("width: $width, height: $height, duration: $duration");
}
}
现在图片信息都拿到了,怎么获取每个像素点的大小,位置,颜色呢?
我们这时候又需要将从 ui.frame
中得到的 ui.Image
并通过 ImageByteFormat.rawRgba
转换成 rgba 的Uint8List
。至于为啥这样转,动动小脑瓜:flutter颜色是32位的,rgba
每一个的范围是 0 - 255
,都占 8位
,转出来 ByteData
是 rgba 的顺序。了解到这个,那下面从像素中获取颜色就很简单了。
Future<void> _decodeGif() async {
// ...
final ByteData byteData = (await image.toByteData(format: ImageByteFormat.rawRgba))!;
final uint8list = Uint8List.view(byteData.buffer);
List<Color> colors = [];
Color color;
for (int j = 0, r, g, b, a; j < uint8list.length; j += 4) {
r = uint8list[j + 0];
g = uint8list[j + 1];
b = uint8list[j + 2];
a = uint8list[j + 3];
color = Color.fromARGB(a, r, g, b);
colors.add(color);
}
print(colors.length);
}
现在所有的颜色信息有了,我们还需要把这些颜色还原到对应的坐标。因为我们的 章鱼猫 是个正方形的gif图,所以我们给上面获取 颜色数组 colors
开个方,获得横向竖向的长度。然后再将一维的 colors
转成二维的。这时候,我们基本得到了点位坐标颜色。
Future<void> _decodeGif() async {
// ...
final int hv = math.sqrt(colors.length).toInt();
final List<List<Color>> newArr = [];
for (int i = 0; i < colors.length; i += hv) {
newArr.add(colors.sublist(i, i + hv > colors.length ? colors.length : i + hv));
}
}
绘制一帧的 章鱼猫
我们修改一下页面的代码,调整成我们需要的结构
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: SingleChildScrollView(
child: FutureBuilder(
future: _decodeGif(),
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset("assets/mona-loading-default.gif"),
const SizedBox(height: 36),
if (snapshot.hasData)
AspectRatio(
aspectRatio: 1,
child: CustomPaint(
size: Size.infinite,
painter: Octocat(frames: snapshot.data, frameIndex: 0),
),
),
],
);
},
initialData: const <List<List<Color>>>[],
),
),
),
);
}
class Octocat extends CustomPainter {
final List<List<List<Color>>> frames;
final int frameIndex;
const Octocat({
required this.frames,
required this.frameIndex,
});
@override
void paint(Canvas canvas, Size size) {}
@override
bool shouldRepaint(covariant Octocat oldDelegate) {
return frameIndex != oldDelegate.frameIndex;
}
}
现在,我们需要在 Octocat
中绘制一帧的内容。又到了传统绘制辅助线的时间
@override
void paint(Canvas canvas, Size size) {
final double width = math.min(size.width, size.height);
final List<List<Color>> frame = frames[frameIndex];
final double perWidth = width / frame.length;
final Paint paint = Paint();
// 绘制辅助线
paint
..isAntiAlias = true
..style = PaintingStyle.stroke
..color = const Color.fromARGB(255, 8, 6, 6);
// 绘制辅助线网格
for (int i = 1; i < frame.length; i++) {
// 绘制横向线条
canvas.drawLine(
Offset(0, i * perWidth), Offset(width, i * perWidth), paint);
// 绘制竖向线条
canvas.drawLine(
Offset(i * perWidth, 0), Offset(i * perWidth, width), paint);
}
}
这辅助线也就一般密集好吧 🤡 ,密集恐惧症福音都算不上。
下一步就是绘制咯
@override
void paint(Canvas canvas, Size size) {
// ...
// 先把画笔设置成填充
paint.style = PaintingStyle.fill;
// 直接双重循环遍历二维数组
for (int i = 0; i < frame.length; i++) {
for (int j = 0; j < frame.length; j++) {
final Color color = frame[i][j];
// 如果是透明的,咋们直接跳过
if (color.alpha == 0) continue;
// 换成每一个点的颜色
paint.color = color;
// 绘制一个个的小方格
canvas.drawRect(
Rect.fromLTWH(i * perWidth, j * perWidth, perWidth, perWidth), paint);
}
}
}
一帧的图已经出来了,但是为毛还差 90°
才是正的,并且还是镜像翻转过的?
怎么动起来
动起来这个就比较简单了,我们直接动起我们的小手,点赞
、 评论
、 关注
、 收藏
、 分享
一波,马上 GET
首先丢给 state 加上 SingleTickerProviderStateMixin
,这样我们的动画才跑的起来
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
// ...
}
接着声明好 AnimationController
和 Animation
,为动画的执行填上不可或缺的一笔
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration:
// 7 表示帧数,160 是我们解码获得的帧数时间,不记得的同学可以网上翻一下,👼
const Duration(milliseconds: 7 * 160),
);
late final Animation<int> _animation =
// 生成 0-6 的帧列表索引
IntTween(begin: 0, end: 6).animate(_controller);
@override
void dispose() {
// 结束的时候
_controller.dispose();
super.dispose();
}
// ...
}
我们在先前代码中是使用 FutureBuilder
来处理数据的,等待数据加载好再取第一帧出来绘制,现在我们要让它动起来,这肯定得修改了。FutureBuilder
是动不起来的。
我们来调整一下UI结构,让它显示正确。这里我们使用 AnimatedBuilder
小小的优化了一下性能,不使用 setState
来刷新整个页面。
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset("assets/mona-loading-default.gif"),
const SizedBox(height: 36),
if (_gifFrames.isNotEmpty)
Transform.rotate(
// 顺时针旋转 90°
angle: math.pi / 2,
child: Transform(
alignment: Alignment.center,
// 横向镜像翻转 180°
transform: Matrix4.rotationX(math.pi),
child: AnimatedBuilder(
animation: _animation,
builder: (BuildContext context, Widget? child) {
return AspectRatio(
aspectRatio: 1,
child: CustomPaint(
size: Size.infinite,
painter: Octocat(
frames: _gifFrames,
frameIndex: _animation.value,
),
),
);
},
),
),
),
],
),
),
),
);
}
接着我们在初始化的时候就执行解码,等待解码完成,立刻刷新页面并开始动画。
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
// ...
List<List<List<Color>>> _gifFrames = [];
@override
void initState() {
super.initState();
_decodeGif().then((value) async {
// 将解码出来的内容赋值给顶层变量
_gifFrames = value;
setState(() {
// 动画需要循环播放,0 1 ... 5 6 0 1 ... 5 6 0 1 ...
_controller.repeat();
});
});
}
// ...
}
注意:我们在使用异步代码去更新页面的时候,我们需要时刻注意判断页面还有没有在挂载中,如果没有,我们就不能去操作UI。这是新手老手都容易犯的问题,我们需要时刻警醒自己是在操作UI。下面我们来看看执行效果
看起来很完美,我们现在把辅助线去掉再看一下效果
是不是一切都完美起来了 🤑
我们来汇总一下代码
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Octocat Loading Anim',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Octocat Loading Anim'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration:
// 7 表示帧数,160 是我们下面获得的帧数时间
const Duration(milliseconds: 7 * 160),
);
late final Animation<int> _animation =
// 生成 0-6 的帧列表索引
IntTween(begin: 0, end: 6).animate(_controller);
List<List<List<Color>>> _gifFrames = [];
@override
void initState() {
super.initState();
_decodeGif().then((value) async {
// 页面没有挂载直接返回
if (!mounted) return;
// 将解码出来的内容赋值给顶层变量
_gifFrames = value;
setState(() {
// 动画需要循环播放,0 1 ... 5 6 0 1 ... 5 6 0 1 ...
_controller.repeat();
});
});
}
@override
void dispose() {
// 结束动画
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset("assets/mona-loading-default.gif"),
const SizedBox(height: 36),
// 如果有数据了再加载
if (_gifFrames.isNotEmpty)
Transform.rotate(
// 顺时针旋转 90°
angle: math.pi / 2,
child: Transform(
alignment: Alignment.center,
// 横向镜像翻转 180°
transform: Matrix4.rotationX(math.pi),
child: AnimatedBuilder(
animation: _animation,
builder: (BuildContext context, Widget? child) {
return AspectRatio(
aspectRatio: 1,
child: CustomPaint(
size: Size.infinite,
painter: Octocat(
frames: _gifFrames,
frameIndex: _animation.value,
),
),
);
},
),
),
),
],
),
),
),
);
}
Future<List<List<List<Color>>>> _decodeGif() async {
final ByteData byteData =
await rootBundle.load("assets/mona-loading-default.gif");
final Uint8List uint8list = Uint8List.view(byteData.buffer);
final ui.Codec codec = await ui.instantiateImageCodec(uint8list);
print("frameCount: ${codec.frameCount}");
final List<List<List<Color>>> frames = [];
for (int i = 0; i < codec.frameCount; i++) {
final ui.FrameInfo frame = await codec.getNextFrame();
final Duration duration = frame.duration;
final ui.Image image = frame.image;
final int width = image.width;
final int height = image.height;
print("width: $width, height: $height, duration: $duration");
final ByteData byteData =
(await image.toByteData(format: ui.ImageByteFormat.rawRgba))!;
final uint8list = Uint8List.view(byteData.buffer);
List<Color> colors = [];
Color color;
for (int j = 0, r, g, b, a; j < uint8list.length; j += 4) {
r = uint8list[j + 0];
g = uint8list[j + 1];
b = uint8list[j + 2];
a = uint8list[j + 3];
color = Color.fromARGB(a, r, g, b);
colors.add(color);
}
print(colors.length);
final int hv = math.sqrt(colors.length).toInt();
final List<List<Color>> newArr = [];
for (int i = 0; i < colors.length; i += hv) {
newArr.add(
colors.sublist(i, i + hv > colors.length ? colors.length : i + hv));
}
frames.add(newArr);
image.dispose();
}
return frames;
}
}
class Octocat extends CustomPainter {
final List<List<List<Color>>> frames;
final int frameIndex;
const Octocat({
required this.frames,
required this.frameIndex,
});
@override
void paint(Canvas canvas, Size size) {
final double width = math.min(size.width, size.height);
final List<List<Color>> frame = frames[frameIndex];
// 每个颜色格子的大小
final double perWidth = width / frame.length;
// 抗锯齿
final Paint paint = Paint()..isAntiAlias = true;
// // 绘制辅助线
// paint
// ..style = PaintingStyle.stroke
// ..color = const Color.fromARGB(255, 8, 6, 6);
//
// // 绘制辅助线网格
// for (int i = 1; i < frame.length; i++) {
// // 绘制横向线条
// canvas.drawLine(
// Offset(0, i * perWidth), Offset(width, i * perWidth), paint);
// // 绘制竖向线条
// canvas.drawLine(
// Offset(i * perWidth, 0), Offset(i * perWidth, width), paint);
// }
// 先把画笔设置成填充
paint.style = PaintingStyle.fill;
Color color;
// 直接双重循环遍历二维数组
for (int i = 0; i < frame.length; i++) {
for (int j = 0; j < frame.length; j++) {
color = frame[i][j];
// 如果是透明的,咋们直接跳过
if (color.alpha == 0) continue;
// 换成每一个点的颜色
paint.color = color;
// 绘制一个个的小方格
canvas.drawRect(
Rect.fromLTWH(i * perWidth, j * perWidth, perWidth, perWidth),
paint);
}
}
}
@override
bool shouldRepaint(covariant Octocat oldDelegate) {
return frameIndex != oldDelegate.frameIndex;
}
}
总结
经过一番辛苦,我们成功的把 章鱼猫 加载动画弄出来了,我们可以轻松的修改他的动画时间,尺寸,动画的曲线。嗯嗯,很不错 😳 。但文章中还有很多的点需要我们注意:
-
很多人看到我在操作 GIF 的时候,把每一帧都提取出来了,那是不是说明我们可以把 GIF 解成一张张图?
-
对这种像素类的,我们获取了太多重复的颜色点,导致我们重复绘制了很多小方格,而我们只需要一个最小的颜色二维数组即可。那怎么获得呢?
-
我们在展示的时候发现,解出来的每张图都是被逆时针旋转 90° 并且进过了镜像翻转,我们怎么在源头解决这个问题,而不是靠
Transform
?? -
我们直接将 GIF 转换成这样的颜色二维数组再绘制图,很明显要比直接使用 Image 组件展示要灵活有效很多,而且没有尺寸限制,放大不会失真,那我们是不是可以把所有的 GIF 都转换成这样再展示?
-
都弄成这样了,是不是可以提取出一个组件出来,方便大家快速简单使用,毕竟不是所有的 UI 小姐姐都会整 Lottie ,flare 这些素材?非常对!!!像我这样的孤家寡人,我一直都期盼有个好康的 UI 小姐姐给我弄点素材,可惜并没有,只能自己找找免费的素材,凑合着用用 🤷♂️
以上这些问题,我将在下一篇文章中揭晓,嘻嘻,尽请期待 🕊。
如果文章对您有帮助的话,欢迎 点赞
、 评论
、 关注
、 收藏
、 分享
,您的支持是我码字的动力,万分感谢!!!🌈
如果文章内容出现错误的地方,欢迎指正,交流,谢谢 😘