【译】Flutter:图像的爆炸动画💥

8,568 阅读6分钟

原文链接 :medium.com/mobile-deve…

本篇文章将展示如何使用 Flutter 完成如下动画效果,本文相关的 Demo 代码在 pub 上的 explode_view 项目可以找到。

首先我们从创建 ExplodeView 对象开始,该对象在 Widget 中主要保存 imagePath 和图像的位置。

class ExplodeView extends StatelessWidget {

  final String imagePath;

  final double imagePosFromLeft;

  final double imagePosFromTop;

  const ExplodeView({
    @required this.imagePath,
    @required this.imagePosFromLeft,
    @required this.imagePosFromTop
  });
  
  @override
  Widget build(BuildContext context) {
    // This variable contains the size of the screen
    final screenSize = MediaQuery.of(context).size;

    return new Container(
      child: new ExplodeViewBody(
          screenSize: screenSize,
          imagePath: imagePath,
          imagePosFromLeft: imagePosFromLeft,
          imagePosFromTop: imagePosFromTop),
    );
  }
 
}

接着开始实现 ExplodeViewBody , 主要看它的 State 实现, _ExplodeViewState 中主要继承了 State 并混入了 TickerProviderStateMixin 用于实现动画执行的需求。

class _ExplodeViewState extends State<ExplodeViewBody> with TickerProviderStateMixin{
    
    GlobalKey currentKey;
    GlobalKey imageKey = GlobalKey();
    GlobalKey paintKey = GlobalKey();
    
    bool useSnapshot = true;
    bool isImage = true;
    
    math.Random random;
    img.Image photo;

    AnimationController imageAnimationController;

    double imageSize = 50.0;
    double distFromLeft=10.0, distFromTop=10.0;

    final StreamController<Color> _stateController = StreamController<Color>.broadcast();

      @override
      void initState() {
        super.initState();
    
        currentKey = useSnapshot ? paintKey : imageKey;
        random = new math.Random();
    
        imageAnimationController = AnimationController(
          vsync: this,
          duration: Duration(milliseconds: 3000),
        );
    
      }
    
      @override
      Widget build(BuildContext context) {
        return Container(
          child: isImage
              ? StreamBuilder(
            initialData: Colors.green[500],
            stream: _stateController.stream,
            builder: (buildContext, snapshot) {
              return Stack(
                children: <Widget>[
                  RepaintBoundary(
                    key: paintKey,
                    child: GestureDetector(
                      onLongPress: () async {
                       //do explode
                      }
                      child: Container(
                        alignment: FractionalOffset((widget.imagePosFromLeft / widget.screenSize.width), (widget.imagePosFromTop / widget.screenSize.height)),
                        child: Transform(
                          transform: Matrix4.translation(_shakeImage()),
                          child: Image.asset(
                            widget.imagePath,
                            key: imageKey,
                            width: imageSize,
                            height: imageSize,
                          ),
                        ),
                      ),
                    ),
                  )
                ],
              );
            },
          ):
              Container(
                child: Stack(
                  children: <Widget>[
                    for(Particle particle in particles) particle.startParticleAnimation()
                  ],
                ),
              )
        );
      }
    
      @override
      void dispose(){
        imageAnimationController.dispose();
        super.dispose();
      }

}

这里省略了部分代码,省略部分在后面介绍。

首先,在 _ExplodeViewState 中初始化了 StreamController<Color> 对象,该对象可以通过 Stream 流来控制 StreamBuilder 触发 UI 重绘制。

然后,在 initState 方法中初始化了 imageAnimationController 作为动画控制器,用于控制图片爆炸前的抖动动画效果。

接着在 build 方法中, 通过条件判断是需要显示图片还是粒子动画,如果需要显示图像,就使用 Image.asset 显示图像效果;外层的 GestureDetector 用于长按时触发爆炸动画效果; StreamBuilder 中的 stream 用于保存图片的颜色和控制重绘的执行。

接着我们还需要实现 Particle 对象,它被用于配置每个粒子的动画效果。

如下代码所示,在 Particle 的构造方法中,需要指定 id(Demo 中是 index)、颜色和颗粒的位置作为参数,之后初始化一个 AnimationController 用于控制粒子的移动效果,通过设置 Tween 来实现动画的在你正负 x 和 y 轴上进行平移,另外还设置了动画过程中颗粒的透明度变化。

Particle({@required this.id, @required this.screenSize, this.colors, this.offsetX, this.offsetY, this.newOffsetX, this.newOffsetY}) {

  position = Offset(this.offsetX, this.offsetY);

  math.Random random = new math.Random();
  this.lastXOffset = random.nextDouble() * 100;
  this.lastYOffset = random.nextDouble() * 100;

  animationController = new AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 1500)
  );

  translateXAnimation = Tween(begin: position.dx, end: lastXOffset).animate(animationController);
  translateYAnimation = Tween(begin: position.dy, end: lastYOffset).animate(animationController);
  negatetranslateXAnimation = Tween(begin: -1 * position.dx, end: -1 * lastXOffset).animate(animationController);
  negatetranslateYAnimation = Tween(begin: -1 * position.dy, end: -1 * lastYOffset).animate(animationController);
  fadingAnimation = Tween<double>(
    begin: 1.0,
    end: 0.0,
  ).animate(animationController);

  particleSize = Tween(begin: 5.0, end: random.nextDouble() * 20).animate(animationController);

}

之后实现 startParticleAnimation() 方法,该方法用于执行粒子动画,该方法通过将上述 animationController 添加到 AnimatedBuilder 这个控件中并执行,之后通过AnimatedBuilderbuilder 方法配合 TransformFadeTransition, 实现动画的移动和透明度变化效果。

 startParticleAnimation() {
    animationController.forward();

    return Container(
      alignment: FractionalOffset(
          (newOffsetX / screenSize.width), (newOffsetY / screenSize.height)),
      child: AnimatedBuilder(
        animation: animationController,
        builder: (BuildContext context, Widget widget) {
          if (id % 4 == 0) {
            return Transform.translate(
                offset: Offset(
                    translateXAnimation.value, translateYAnimation.value),
                child: FadeTransition(
                  opacity: fadingAnimation,
                  child: Container(
                    width: particleSize.value > 5 ? particleSize.value : 5,
                    height: particleSize.value > 5 ? particleSize.value : 5,
                    decoration:
                        BoxDecoration(color: colors, shape: BoxShape.circle),
                  ),
                ));
          } else if (id % 4 == 1) {
            return Transform.translate(
                offset: Offset(
                    negatetranslateXAnimation.value, translateYAnimation.value),
                child: FadeTransition(
                  opacity: fadingAnimation,
                  child: Container(
                    width: particleSize.value > 5 ? particleSize.value : 5,
                    height: particleSize.value > 5 ? particleSize.value : 5,
                    decoration:
                        BoxDecoration(color: colors, shape: BoxShape.circle),
                  ),
                ));
          } else if (id % 4 == 2) {
            return Transform.translate(
                offset: Offset(
                    translateXAnimation.value, negatetranslateYAnimation.value),
                child: FadeTransition(
                  opacity: fadingAnimation,
                  child: Container(
                    width: particleSize.value > 5 ? particleSize.value : 5,
                    height: particleSize.value > 5 ? particleSize.value : 5,
                    decoration:
                        BoxDecoration(color: colors, shape: BoxShape.circle),
                  ),
                ));
          } else {
            return Transform.translate(
                offset: Offset(negatetranslateXAnimation.value,
                    negatetranslateYAnimation.value),
                child: FadeTransition(
                  opacity: fadingAnimation,
                  child: Container(
                    width: particleSize.value > 5 ? particleSize.value : 5,
                    height: particleSize.value > 5 ? particleSize.value : 5,
                    decoration:
                        BoxDecoration(color: colors, shape: BoxShape.circle),
                  ),
                ));
          }
        },
      ),
    );
  }
 )

如上代码所示,这里实现了四种不同方向的例子移动,通过使用不同的方向值和 offset ,然后根据上面定义的 Tween 对象配置动画,最后使用了圆形形状的 BoxDecoration 和可变的高度和宽度创建粒子。

这样就完成了 Particle 类的实现,接下来介绍从图像中获取颜色的实现。

Future<Color> getPixel(Offset globalPosition, Offset position, double size) async {
  if (photo == null) {
    await (useSnapshot ? loadSnapshotBytes() : loadImageBundleBytes());
  }

  Color newColor = calculatePixel(globalPosition, position, size);
  return newColor;
}

Color calculatePixel(Offset globalPosition, Offset position, double size) {

  double px = position.dx;
  double py = position.dy;


  if (!useSnapshot) {
    double widgetScale = size / photo.width;
    px = (px / widgetScale);
    py = (py / widgetScale);

  }


  int pixel32 = photo.getPixelSafe(px.toInt()+1, py.toInt());

  int hex = abgrToArgb(pixel32);

  _stateController.add(Color(hex));

  Color returnColor = Color(hex);

  return returnColor;
}

如上所示代码,实现了从图像中获取指定位置的像素颜色,在 Demo 中使用了不同的方法来加载和设置图像的 bytes(loadSnapshotBytes() 或者 loadImageBundleBytes()),从而获取颜色数据。

// Loads the bytes of the image and sets it in the img.Image object
  Future<void> loadImageBundleBytes() async {
    ByteData imageBytes = await rootBundle.load(widget.imagePath);
    setImageBytes(imageBytes);
  }

  // Loads the bytes of the snapshot if the img.Image object is null
  Future<void> loadSnapshotBytes() async {
    RenderRepaintBoundary boxPaint = paintKey.currentContext.findRenderObject();
    ui.Image capture = await boxPaint.toImage();
    ByteData imageBytes =
        await capture.toByteData(format: ui.ImageByteFormat.png);
    setImageBytes(imageBytes);
    capture.dispose();
  }
  
  void setImageBytes(ByteData imageBytes) {
    List<int> values = imageBytes.buffer.asUint8List();
    photo = img.decodeImage(values);
  }

现在当我们长按图像时,就可以进入散射粒子的最终动画,并执行以下方法开始生成粒子:

RenderBox box = imageKey.currentContext.findRenderObject();
Offset imagePosition = box.localToGlobal(Offset.zero);
double imagePositionOffsetX = imagePosition.dx;
double imagePositionOffsetY = imagePosition.dy;

double imageCenterPositionX = imagePositionOffsetX + (imageSize / 2);
double imageCenterPositionY = imagePositionOffsetY + (imageSize / 2);
for(int i = 0; i < noOfParticles; i++){
  if(i < 21){
    getPixel(imagePosition, Offset(imagePositionOffsetX + (i * 0.7), imagePositionOffsetY - 60), box.size.width).then((value) {
      colors.add(value);
    });
  }else if(i >= 21 && i < 42){
    getPixel(imagePosition, Offset(imagePositionOffsetX + (i * 0.7), imagePositionOffsetY - 52), box.size.width).then((value) {
      colors.add(value);
    });
  }else{
    getPixel(imagePosition, Offset(imagePositionOffsetX + (i * 0.7), imagePositionOffsetY - 68), box.size.width).then((value) {
      colors.add(value);
    });
  }
}
Future.delayed(Duration(milliseconds: 3500), () {

  for(int i = 0; i < noOfParticles; i++){
    if(i < 21){
      particles.add(Particle(id: i, screenSize: widget.screenSize, colors: colors[i].withOpacity(1.0), offsetX: (imageCenterPositionX - imagePositionOffsetX + (i * 0.7)) * 0.1, offsetY: (imageCenterPositionY - (imagePositionOffsetY - 60)) * 0.1, newOffsetX: imagePositionOffsetX + (i * 0.7), newOffsetY: imagePositionOffsetY - 60));
    }else if(i >= 21 && i < 42){
      particles.add(Particle(id: i, screenSize: widget.screenSize, colors: colors[i].withOpacity(1.0), offsetX: (imageCenterPositionX - imagePositionOffsetX + (i * 0.5)) * 0.1, offsetY: (imageCenterPositionY - (imagePositionOffsetY - 52)) * 0.1, newOffsetX: imagePositionOffsetX + (i * 0.7), newOffsetY: imagePositionOffsetY - 52));
    }else{
      particles.add(Particle(id: i, screenSize: widget.screenSize, colors: colors[i].withOpacity(1.0), offsetX: (imageCenterPositionX - imagePositionOffsetX + (i * 0.9)) * 0.1, offsetY: (imageCenterPositionY - (imagePositionOffsetY - 68)) * 0.1, newOffsetX: imagePositionOffsetX + (i * 0.7), newOffsetY: imagePositionOffsetY - 68));
    }
  }

  setState(() {
    isImage = false;
  });
});

如上代码所示,这里使用了 RenderBox 类获得的图像的位置,然后从上面定义的 getPixel() 方法获取颜色。

获取的颜色是从图像上的三条水平线中提取到的,并在同一条线上使用了随机偏移,这样可以从图像中获得到更多的颜色,然后使用适当的参数值在不同位置使用 Particle 创建粒子。

当然这里还有 3.5 秒的延迟执行,而在这个延迟过程中会出现图像抖动。通过使用 Matrix4.translation() 方法可以简单地实现抖动,该方法使用与下面所示的 _shakeImage 方法来实现不同的偏移量来快速转换图像。

Vector3 _shakeImage() {
  return Vector3(math.sin((imageAnimationController.value) * math.pi * 20.0) * 8, 0.0, 0.0);
}

最后,在摇动图像并创建了粒子之后图像消失,并且调用之前的 startParticleAnimation 方法,这完成了在 Flutter 中的图像爆炸。

最后如下就可以引入 ExplodeView

ExplodeView(
   imagePath: path, 
   imagePosFromLeft: xxxx, 
   imagePosFromTop: xxxx
),

Demo 地址: github.com/mdg-soc-19/…

ps:因为不像 Android 上可以获取 Bitmap 的横竖坐标上的二维像素点,所以没办法实现整个图片原地爆炸的效果

资源推荐