欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2024-03-24 09:37:16
...

原文链接 :https://medium.com/mobile-development-group/flutter-explosion-animation-for-image-3dd5e2863427

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

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

首先我们从创建 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 中的图像爆炸。

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

最后如下就可以引入 ExplodeView

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

Demo 地址: https://github.com/mdg-soc-19/explode-view/blob/master/lib/explode_view.dart

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

资源推荐

  • Github : https://github.com/CarGuo
  • 开源 Flutter 完整项目:https://github.com/CarGuo/GSYGithubAppFlutter
  • 开源 Flutter 多案例学习型项目: https://github.com/CarGuo/GSYFlutterDemo
  • 开源 Fluttre 实战电子书项目:https://github.com/CarGuo/GSYFlutterBook
  • 开源 React Native 项目:https://github.com/CarGuo/GSYGithubApp

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

相关标签: Android开发