【譯】Flutter:影象的爆炸動畫?

戀貓de小郭發表於2020-02-17

原文連結 :medium.com/mobile-deve…

本篇文章將展示如何使用 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 地址: github.com/mdg-soc-19/…

ps:因為不像 Android 上可以獲取 Bitmap 的橫豎座標上的二維畫素點,所以沒辦法實現整個圖片原地爆炸的效果

資源推薦

【譯】Flutter:影象的爆炸動畫?

相關文章