在Flutter中实现谷歌灭霸彩蛋

969 阅读5分钟

示例代码

去年用Objective-Cpotato04 老哥实现的 Swift 版本的 在 iOS 中实现谷歌灭霸彩蛋 仿写了一遍,现在刚自学 Flutter,现在献上 Flutter 版本的实现;

沙化动画

复原动画
由于是模拟器录制,所以录制的fps 有点低(最高12fps), 更清晰的请参阅沸点

# 响指动画

参阅上篇文章Flutter中使用sprites精灵图的做法,这里直接贴出代码部分;

绘制精灵图的代码:


// animatable_sprite.dart

import 'package:flutter/material.dart';
import 'dart:ui' as ui;

class AnimatableSprite extends StatelessWidget {
  final ui.Image img;
  final int showIndex;

  const AnimatableSprite({
    Key key,
    @required this.img,
    this.showIndex,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      child: CustomPaint(
        painter: _SpritesPainter(
          img,
          showIndex: showIndex,
        ),
      ),
    );
  }
}

class _SpritesPainter extends CustomPainter {
  final ui.Image _img; // 图片
  Paint mainPaint;

  int _showIndex = 0;

  _SpritesPainter(
    this._img, {
    @required int showIndex,
  }) {
    this._showIndex = showIndex;
    mainPaint = Paint();
  }

  @override
  void paint(ui.Canvas canvas, ui.Size size) {
    Rect rect = Offset(0, 0) & size;
    // 裁剪绘制区域
    canvas.clipRect(rect);
    if (_img != null) {
      double showSize = _img.height.toDouble();
      Rect src = Rect.fromLTRB(
        _showIndex * showSize,
        0,
        (_showIndex + 1) * showSize,
        showSize,
      );
      // src: _img将要显示的区域, rect: _img将要显示的区域实际被绘制的区域
      canvas.drawImageRect(_img, src, rect, mainPaint);
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    if (oldDelegate is _SpritesPainter) {
      _SpritesPainter oldPainter = oldDelegate;
      if (oldPainter._showIndex != this._showIndex ||
          oldPainter.mainPaint != this.mainPaint) {
        return true;
      }
    }
    return false;
  }
}

控制动画的部分代码

// thanos_gauntlet.dart

import 'package:audioplayers/audio_cache.dart';
import 'package:flutter/material.dart';
import 'package:thanos_snap_flutter/aniamte/animatable_sprite.dart';
import 'dart:ui' as ui;

enum ThanosGauntletAction {
  snap,
  reverse,
}

class _ThanosGauntletState extends State<ThanosGauntlet>
    with TickerProviderStateMixin {
  ui.Image snapImg;
  ui.Image reverseImg;
  AnimationController snapController;
  Animation snapAnimation;
  ...
   _initAnimation() {
    snapController = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );
    snapAnimation = IntTween(
      begin: 0,
      end: snapCount,
    ).animate(snapController)
      ..addListener(_handleAnimationChange)
      ..addStatusListener(_handleAnimationStatus);
      
    ...
  }
  
  _handleAnimationChange() {
    setState(() {
      // do nothing
    });
  }

  _handleAnimationStatus(AnimationStatus status) {
    // nofity status
    ...
  }
  
  @override
  Widget build(BuildContext context) {
    if (snapImg != null && reverseImg != null) {
      return GestureDetector(
        child: Container(
          height: 40,
          width: 40,
          child: AnimatableSprite(
            img: !showSnap ? snapImg : reverseImg,
            showIndex: !showSnap ? snapAnimation.value : reverseAnimation.value,
          ),
        ),
        onTap: _showAnimation,
      );
    } else {
      return Container();
    }
  }
}

就是利用_aniation.value 来不停地更新 AnimatableSprite 的绘制区域以达到动画的效果;

# 沙化消失

核心思想还是将图片处理为像素,然后根据处理后的像素再生成 ui.Image 使用 canvas 进行绘图操作;

像素化处理

新建 dust_effect_draw.dart 文件;

/// 拆解图像
  _initImages(ui.Image image) async {
    ui.Image originImg = image;
    int imageWidth = originImg.width;
    ByteData byteData = await originImg.toByteData();
    Uint8List originList = new Uint8List.view(byteData.buffer);
    int length = originList.length;
    // RGBA信息
    List<Uint8List> framePixels = new List(imageCount);
    for (int i = 0; i < imageCount; i++) {
      framePixels[i] = Uint8List(length);
    }
    // 遍历 originList
    for (int idx = 0; idx < length; idx++) {
      // 注释 1
      if (idx % 4 == 0 && idx > 3) {
        double column = (idx / 4) % imageWidth;
        // 每个循环2次
        for (int i = 0; i < 2; i++) {
          // 注释 2
          double factor = Random().nextDouble() + 2 * (column / imageWidth);
          int index = (imageCount * (factor / 3)).floor();
          Uint8List tmp = framePixels[index];
          tmp[idx - 1] = originList[idx - 1];
          tmp[idx - 2] = originList[idx - 2];
          tmp[idx - 3] = originList[idx - 3];
          tmp[idx - 4] = originList[idx - 4];
        }
      }
    }
    List<DustEffectModel> imageList = List();
    for (var e in framePixels) {
      ui.Image outputImage;
      if (widget.rebuildHeader) {
        // 注释 3
        Bitmap bitmap = Bitmap.fromHeadless(
          originImg.width,
          originImg.height,
          e,
        );
        outputImage = await bitmap.buildImage();
      } else {
        outputImage = await ImageLoader.loader.loadImageByUint8List(e);
      }
      DustEffectModel model = DustEffectModel(outputImage);
      imageList.add(model);
    }
    setState(() {
      this.dustModelList = imageList;
    });
  }

注释 1: 在我们通过ui.Image生成的 Unit8List 数组中,每个存储一个 0~255的数字来表示 R,G, B, A 中的一个信息,从 index = 0 开始每 连续的4位表示一个完整像素的RGBA信息,

注释 2: 这里的 index 确保图片左边的部分分布在 framePixels 数组的靠前位置,具体请参阅 potato04原文所述;

注释 3: 这里使用了第三方库用以生成正确的 ui.Image,应为我们直接通过ui.Image得到的 Uint8List是缺失图片信息的其内部仅仅存储了image的像素信息,因此通过Bitmap 来正确的添加一个 RGBA 头部信息,以保证生成正确的图片;

直接调用

ui.Codec codec = await ui.instantiateImageCodec(list, targetWidth: width, targetHeight: height);
ui.FrameInfo frame = await codec.getNextFrame();

会报如下错误:

[VERBOSE-2:ui_dart_state.cc(157)] Unhandled Exception: Exception: Could not instantiate image codec.
#0      _futurize (dart:ui/painting.dart:4419:5)
#1      instantiateImageCodec (dart:ui/painting.dart:1722:10)
#2      _decodeImageFromListAsync (dart:ui/painting.dart:1751:29)

更详细叙述见flutter/issues/44774

封装处理后图片

新建dust_effect_model.dart 并添加以下代码:

class DustEffectModel {

  ui.Image image;

  DustEffectModel(this.image);
  /*
  Point get translate {
    if (_translate != null) {
      return _translate;
    }
    double radian1 = pi / 12 * (Random().nextDouble() - 0.5);
    double random = pi * 2 * (Random().nextDouble() - 0.5);
    double transX = 30 * cos(random);
    double transY = 15 * sin(random);
    double realTransX = transX * cos(radian1) - transY * sin(radian1);
    double realTransY = transY * cos(radian1) + transX * sin(radian1);

    _translate = Point(realTransX, realTransY);
    return _translate;
  }
  Point _translate;
  */
  double get rotation => _rotation;


  ui.Path _path;
  ui.Path get path {
    if(_path != null) return _path;
    ui.Path path = ui.Path();
    double radian1 = pi / 12 * (Random().nextDouble() - 0.5);
    double random = pi * 2 * (Random().nextDouble() - 0.5);
    double transX = 30 * cos(random);
    double transY = 15 * sin(random);
    double realTransX = transX * cos(radian1) - transY * sin(radian1);
    double realTransY = transY * cos(radian1) + transX * sin(radian1);
    path.moveTo(0, 0);
    path.quadraticBezierTo(transX, transY, realTransX, realTransY);
    _path = path;
    return _path;
  }

  Point currentPoint(double scale) {
    ui.Path totalPath = path;
    ui.PathMetrics pms = totalPath.computeMetrics();
    ui.PathMetric pm = pms.elementAt(0);
    ui.Tangent t = pm.getTangentForOffset(scale);
    return Point(t.position.dx, t.position.dy);
  }

  double _rotation = pi / 12 * (Random().nextDouble() - 0.5);
}

其中:

  • image 就是像素化处理后生成的 ui.Image
  • _pathimagetranslate 动画的路径;
  • _rotationimage 旋转的角度;
  • currentPoint() 是获得 _path 上点的位置的

_path_rotation 都是随机生成的代码逻辑与原文逻辑相同不做赘述。 currentPoint() 内部使用的全部是 dart:ui 提供的方法,具体解释请参阅官方文档(官方文档真香,Apple 开发者表示羡慕);

进行绘制
  @override
  void paint(Canvas canvas, Size size) {
    Rect rect = Offset(0, 0) & size;
    int length = dustList.length;
    // 最小刻度
    double miniScale = delay / length;
    for (var model in dustList) {
      int index = dustList.indexOf(model);
      // 根据 index 和 传入的 value 来计算 index 对应的 image 的动画进度
      double indexStart = value - (miniScale * index);
      // 边界值处理
      indexStart = indexStart > 0
          ? (indexStart < duration ? indexStart : duration.toDouble())
          : 0.0;
      // 计算进度
      double showScale = indexStart / duration;

      ui.Image image = model.image;
      Rect src = Rect.fromLTRB(
        0,
        0,
        image.width.toDouble(),
        image.height.toDouble(),
      );
      double rotation = model.rotation * showScale;
      Point translate = model.currentPoint(showScale);
      canvas.save();
      // 计算画布中心轨迹圆半径
      double r = sqrt(pow(size.width, 2) + pow(size.height, 2));
      // 计算画布中心点初始弧度
      double startAngle = atan(size.height / size.width);
      // 计算画布初始中心点坐标
      Point p0 = Point(
        r * cos(startAngle),
        r * sin(startAngle),
      );
      // 需要旋转的弧度
      double xAngle = rotation;
      // 计算旋转后的画布中心点坐标
      Point px = Point(
        r * cos(xAngle + startAngle) - translate.x,
        r * sin(xAngle + startAngle) - translate.y,
      );
      // 先平移画布
      canvas.translate((p0.x - px.x) / 2, (p0.y - px.y) / 2);
      // 后旋转
      canvas.rotate(xAngle);
      // 设置透明度
      mPaint.color = Color.fromRGBO(0, 0, 0, (1.0 - showScale));
      canvas.drawImageRect(image, src, rect, mPaint);
      canvas.restore();
    }
  }

如代码注释所述,根据传入的 value 计算出当前 index 对应图片的动画进度来绘制图片;

最后我投了个懒,复原动画直接使用了 AnimationControllerreverse进行的🤪

# 完结

核心代码讲解完毕,感兴趣的话,请点赞支持一下吧😉

完整代码见文章顶部;