【flutter】用flutter实现一个炫酷的Github章鱼猫加载动画(一)

4,747 阅读9分钟

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

  经常逛 Github 的同学可能发现了,GitHub 的加载列表多了个 章鱼猫 ,就是下图那个动一动的玩意儿,比先前单调的菊花转不知要好多少倍。所以我来整活了啦:用 flutter来实现一个和下图一毛一样的动画。为单调的加载加上,一丝可爱,一丝俏皮,一丝炫酷 ...?觉得棒棒的同学,请不要吝啬您的 点赞评论关注收藏分享 ,您的支持是我码字的动力,万分感谢!!!🌈

mona-loading-default.gif

开始分析

  要实现一个上面动图的动画效果,不就直接用 Image 组件加载一下上面的gif图就实现了吗?是的,没错,确实 👊,本篇文章到此结束,谢谢大家的观看。🙌

嘻嘻,我怎么可能这么肤浅呢,要弄出这么一个动画,当然得是纯纯的Flutter代码来实现咯 😇。GIF 的缺点我就不多说了,懂的都懂,下面让我正式开始吧

  我们先观察一下这个图,很明显,这只 章鱼猫 由一个个 方块像素点 组成,每次游动是不同的像素点之间变化。对于 GIF 来说,那就是一帧帧的图在切换。如果我们要用flutter绘制这个 章鱼猫 ,那么我们需要知道🤔

  • 章鱼猫 游起来有多少帧,也就是有多少不同的图
  • 每一帧图 章鱼猫 每一个像素点所在的位置以及位置的颜色

如果我们知道上面的这两个关键要素,那绘制这个 章鱼猫 将不再是啥难事儿🤩,用上我们的老朋友 CustomPaint ,将分分钟搞定

如何实现

  我们先创建一个 flutter 项目 octocat_loading_anim ,删除一些不必要的代码,整一个干净的代码环境。(ps:创建 flutter项目的过程,不懂的小伙伴可以参考我先前的文章 从零开始的五星红旗 )

image.png

然后我们创建一个 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"),
      ),
    );
  }
}

image.png

运行的很完美,我们已经可以在应用中看到 章鱼猫 游起来了。下面我们正式开始 🤣

如何拿到 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 包,对 GIFUint8List 数据进行解码,得到 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 里面有些啥

image.png

喏喏喏,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}");
}

image.png

如何获取每一帧图需要绘制像素点位置

  上一个问题中,我们已经拿到了 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");
  }
}

image.png

现在图片信息都拿到了,怎么获取每个像素点的大小,位置,颜色呢?

我们这时候又需要将从 ui.frame 中得到的 ui.Image 并通过 ImageByteFormat.rawRgba 转换成 rgbaUint8List。至于为啥这样转,动动小脑瓜:flutter颜色是32位的,rgba 每一个的范围是 0 - 255 ,都占 8位,转出来 ByteDatargba 的顺序。了解到这个,那下面从像素中获取颜色就很简单了。

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);
  }
}

这辅助线也就一般密集好吧 🤡 ,密集恐惧症福音都算不上。

image.png

下一步就是绘制咯

  @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);
    }
  }
}

image.png

一帧的图已经出来了,但是为毛还差 90° 才是正的,并且还是镜像翻转过的?

怎么动起来

动起来这个就比较简单了,我们直接动起我们的小手,点赞评论关注收藏分享 一波,马上 GET

首先丢给 state 加上 SingleTickerProviderStateMixin ,这样我们的动画才跑的起来

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  // ...
}

接着声明好 AnimationControllerAnimation ,为动画的执行填上不可或缺的一笔

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。下面我们来看看执行效果

image.png

看起来很完美,我们现在把辅助线去掉再看一下效果

xxx11.gif

是不是一切都完美起来了 🤑

我们来汇总一下代码

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;
  }
}

总结

  经过一番辛苦,我们成功的把 章鱼猫 加载动画弄出来了,我们可以轻松的修改他的动画时间,尺寸,动画的曲线。嗯嗯,很不错 😳 。但文章中还有很多的点需要我们注意:

  1. 很多人看到我在操作 GIF 的时候,把每一帧都提取出来了,那是不是说明我们可以把 GIF 解成一张张图?

  2. 对这种像素类的,我们获取了太多重复的颜色点,导致我们重复绘制了很多小方格,而我们只需要一个最小的颜色二维数组即可。那怎么获得呢?

  3. 我们在展示的时候发现,解出来的每张图都是被逆时针旋转 90° 并且进过了镜像翻转,我们怎么在源头解决这个问题,而不是靠 Transform ??

  4. 我们直接将 GIF 转换成这样的颜色二维数组再绘制图,很明显要比直接使用 Image 组件展示要灵活有效很多,而且没有尺寸限制,放大不会失真,那我们是不是可以把所有的 GIF 都转换成这样再展示?

  5. 都弄成这样了,是不是可以提取出一个组件出来,方便大家快速简单使用,毕竟不是所有的 UI 小姐姐都会整 Lottieflare 这些素材?非常对!!!像我这样的孤家寡人,我一直都期盼有个好康的 UI 小姐姐给我弄点素材,可惜并没有,只能自己找找免费的素材,凑合着用用 🤷‍♂️

以上这些问题,我将在下一篇文章中揭晓,嘻嘻,尽请期待 🕊。

xxx131.gif


如果文章对您有帮助的话,欢迎 点赞评论关注收藏分享 ,您的支持是我码字的动力,万分感谢!!!🌈

如果文章内容出现错误的地方,欢迎指正,交流,谢谢 😘

参考资料