Flutter2.0 繪製旋轉太空人+蛛網效果

懶洋君發表於2021-03-30

前言

最近華為手錶的太空人錶盤突然間火了,錶盤上那個旋轉的太空人呆萌可愛。奈何沒有一款華為手錶,作為一名合格的程式猿,當然要擼起袖子自己來畫一個啦~

鑑於最近Flutter推出了2.0穩定版本,除了對移動端Android、iOS的支援外,Web端和桌面端的支援也整合到了Flutter2.0版本中,新特性支援空指標安全。本次通過Flutter2.0來編寫封面圖展示的效果。

編寫思路

1、旋轉太空人

在構思太空人如何繪製時,此文Flutter繪製-09-華為太空人-殘次版給我提供了繪製的思路,感謝作者的分享,站在巨人的肩膀上,才能走的更高更遠。

在調研之後,發現直接用程式碼動態生成的方式,可能不大適合,懶洋君繪畫水平也不高,畫出來肯定也是不好看。所以轉換了個思維,直接用視訊播放器來播放(這步偷了個懶,有更好實現方式的朋友,可以提供下新思路)。

2、動態蛛網

動態蛛網之前用Android實現了一版《Android實現蛛網背景效果》,是參考canvas-nest.js來寫的(網頁效果)。Adroid版本的實現沒有寫的很具體,感謝《五彩蛛網》的作者,將動態蛛網的繪製過程進行了分解,講解的很詳細。這次嘗試用Flutter來實現。

具體實現

旋轉太空人

1、視訊控制元件

決定使用視訊來播放太空人,那麼就用普及率最高的video_player來編寫。

視訊控制元件的程式碼: 這部分程式碼和video_player提供的example一樣,只修改了VideoPlayerController的建立。通過assets資源來播放視訊:VideoPlayerController.asset(this.videoAssetsUrl)

import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';

class VideoView extends StatefulWidget {
  final String videoAssetsUrl;

  @override
  _VideoViewState createState() => _VideoViewState(videoAssetsUrl);

  VideoView(this.videoAssetsUrl);
}

class _VideoViewState extends State<VideoView> {
  String videoAssetsUrl;
  late VideoPlayerController _controller;

  _VideoViewState(this.videoAssetsUrl);

  @override
  void initState() {
    super.initState();
    // 通過assets資源來播放視訊
    _controller = VideoPlayerController.asset(this.videoAssetsUrl)
      ..initialize().then((_) {
        // Ensure the first frame is shown after the video is initialized, even before the play button has been pressed.
        setState(() {
          _controller.setLooping(true);
          _controller.play();
        });
      });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 100,
      height: 100,
      child: _controller.value.isInitialized
          ? AspectRatio(
              aspectRatio: _controller.value.aspectRatio,
              child: VideoPlayer(_controller),
            )
          : Container(),
    );
  }

  @override
  void dispose() {
    super.dispose();
    _controller.dispose();
  }
}

複製程式碼

2、在pubspec.yaml中定義視訊資源位置

assets:
  - assets/video/
複製程式碼

視訊效果預覽: 視訊效果預覽.gif

動態蛛網

1、配置引數

  // 小球可取的顏色值
  final List<Color>? ballColors;

  /// 小球總數
  final int totalCount;

  /// 小球連線最大的距離
  final double maxDistance;

  /// X軸加速度範圍,範圍越大,小球速度相差就越大
  final int velocityXRange;

  /// Y軸加速度範圍,範圍越大,小球速度相差就越大
  final int velocityYRange;

  // 每次重新整理,單位位移的畫素,大於0就行,越小,小球運動的越慢
  final double eachMovePixel;

  /// 小球連線的寬度
  final double lineWidth;

  /// 連線最大的透明度0~1
  final double maxAlpha;

  /// 小球半徑
  final double radius;

  /// 觸控影響半徑
  final double touchRadius;

  /// 觸控影響半徑
  final Color? touchColor;
複製程式碼

2、小球物件資訊

import 'package:flutter/material.dart';

/// 小球
class Point {
  /// X軸加速度
  int velocityX;

  /// Y軸加速度
  int velocityY;

  /// X軸當前位置
  double x;

  /// Y當前位置
  double y;

  /// 小球顏色
  Color color;

  Point(this.x, this.y,
      {this.velocityX = 0, this.velocityY = 0, this.color = Colors.green});
}

複製程式碼

3、根據配置,在initState()中構建運動小球列表

  // 初始化小球列表
  for (int i = 0; i < settings!.totalCount; i++) {
    // 在控制元件大小範圍內,隨機新增小球
    double x = Random().nextInt(width.toInt()).toDouble();
    double y = Random().nextInt(height.toInt()).toDouble();
    // 下面是設定初始加速度
    // 通過下面的公式,防止出現加速度為0,且加速度可為正負velocityXRange
    int velocityX = (Random().nextInt(settings!.velocityXRange) + 1) *
        (1 - 2 * Random().nextInt(2));
    int velocityY = (Random().nextInt(settings!.velocityYRange) + 1) *
        (1 - 2 * Random().nextInt(2));
    Color color;
    if (settings!.ballColors != null && settings!.ballColors!.length > 0) {
      color = settings!
          .ballColors![Random().nextInt(settings!.ballColors!.length)];
    } else {
      color = Colors.green;
    }
    ballList.add(Point(x, y,
        velocityX: velocityX, velocityY: velocityY, color: color));
  }
複製程式碼

4、使用CustomPainter來繪製動畫,需要重寫void paint(Canvas canvas, Size size)bool shouldRepaint(CustomPainter oldDelegate)兩個方法。

/// 自定義PointPainter
class PointPainter extends CustomPainter {
  math.Point? touchPoint;
  Paint ballPaint;
  Paint touchPaint = Paint();
  List<Point> ballList;
  Settings settings;

  PointPainter(this.settings, this.touchPoint, this.ballPaint, this.ballList) {
    if (settings.touchColor == null) {
      touchPaint.color = Color.fromARGB(81, 176, 176, 176);
    } else {
      touchPaint.color = settings.touchColor!;
    }
  }
  
  @override
  void paint(Canvas canvas, Size size) {
    // 在這裡進行真正的繪製
  }
  
  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    // 在實際場景中正確利用此回撥可以避免重繪開銷,目前簡單返回true
    // 當條件變化時是否需要重畫
    return true;
  }
}
複製程式碼

5、獲取觸控點,使用GestureDetector來獲取觸控事件,並記錄點選位置touchPoint

  GestureDetector(
    child: CustomPaint(
      painter:
          new PointPainter(settings!, touchPoint, ballPaint, ballList),
    ),
    onPanStart: (DragStartDetails details) {
      setState(() {
        touchPoint = math.Point(
            details.localPosition.dx, details.localPosition.dy);
      });
    },
    onPanUpdate: (DragUpdateDetails details) {
      setState(() {
        touchPoint = math.Point(
            details.localPosition.dx, details.localPosition.dy);
      });
    },
    onPanEnd: (DragEndDetails details) {
      setState(() {
        touchPoint = null;
      });
    },
  ),
複製程式碼

6、在void paint(Canvas canvas, Size size)中繪製動畫

  @override
  void paint(Canvas canvas, Size size) {
    // 繪製小球列表
    drawBallList(canvas);

    // 繪製點選區域
    drawTouchCircle(canvas);
  }
複製程式碼

7、繪製運動小球及觸控點,還有連線

  /// 繪製小球列表
  void drawBallList(Canvas canvas) {
    Paint linePaint = Paint();
    // 繪製小球列表
    ballList.forEach((ball1) {
      linePaint.strokeWidth = settings.lineWidth;
      ballPaint.color = ball1.color;
      // 繪製小球
      canvas.drawCircle(Offset(ball1.x, ball1.y), settings.radius, ballPaint);

      // 繪製小球與觸控點之間的連線
      drawTouchLine(ball1, linePaint, canvas);

      ballList.forEach((ball2) {
        // 繪製小球之間的連線
        if (ball1 != ball2) {
          int distance = point2Distance(ball1, ball2);
          if (distance <= settings.maxDistance) {
            // 小於最大連線距離,才進行連線

            // 線條透明度,距離越遠越透明
            double alpha =
                (1.0 - distance / settings.maxDistance) * settings.maxAlpha;
            Color color = ball1.color;
            linePaint.color = Color.fromARGB(
                (alpha * 255).toInt(), color.red, color.green, color.blue);

            // 繪製兩個小球之間的連線
            canvas.drawLine(
                Offset(ball1.x, ball1.y), Offset(ball2.x, ball2.y), linePaint);
          }
        }
      });
    });
  }

  /// 繪製點選區域
  void drawTouchCircle(Canvas canvas) {
    if (touchPoint != null) {
      canvas.drawCircle(
          Offset(touchPoint!.x.toDouble(), touchPoint!.y.toDouble()),
          settings.touchRadius,
          touchPaint);
    }
  }
  
  /// 繪製小球與觸控點之間的連線
  void drawTouchLine(Point ball1, Paint linePaint, Canvas canvas) {
    if (touchPoint != null) {
      int distance = pointNum2Distance(
          ball1.x, touchPoint!.x.toDouble(), ball1.y, touchPoint!.y.toDouble());
      if (distance <= settings.touchRadius) {
        // 線條透明度,距離越近越透明
        double alpha = distance / settings.touchRadius * settings.maxAlpha;
        Color color = ball1.color;
        linePaint.color = Color.fromARGB(
            (alpha * 255).toInt(), color.red, color.green, color.blue);

        // 繪製兩個小球之間的連線
        canvas.drawLine(
            Offset(ball1.x, ball1.y),
            Offset(touchPoint!.x.toDouble(), touchPoint!.y.toDouble()),
            linePaint);
      }
    }
  }

  /// 計算兩點之間的距離
  int point2Distance(Point p1, Point p2) {
    return sqrt(pow(p1.x - p2.x, 2) + pow(p1.y - p2.y, 2)).toInt();
  }

  /// 計算兩點之間的距離
  int pointNum2Distance(double x1, double x2, double y1, double y2) {
    return sqrt(pow(x1 - x2, 2) + pow(y1 - y2, 2)).toInt();
  }
複製程式碼

讓動畫動起來

1、通過addPersistentFrameCallback回撥,在回撥中計算每一幀效果的數值,不斷重新整理幀,每一幀組合起來就實現動畫效果了。

    WidgetsBinding? widgetsBinding = WidgetsBinding.instance;
    widgetsBinding?.addPostFrameCallback((callback) {
      // 頁面渲染第一幀的回撥
      widgetsBinding.addPersistentFrameCallback((callback) {
        // 持久幀的回撥,每一幀重新整理都回觸發
        if (mounted) {
          setState(() {
              // 在這邊進行數值的計算賦值
          });
          widgetsBinding.scheduleFrame();
        }
      });
    });
複製程式碼

2、setState((){});中具體的計算過程:

  setState(() {
    catchBallCount = 0;
    ballList.forEach((ball) {
      // 計算點選時,小球的偏移量,營造聚攏效果
      calculateTouchOffset(ball);

      // 當遇到邊界時,需要改變x加速度方向
      if (ball.x >=
          width - settings!.radius / 2 - settings!.lineWidth / 2) {
        if (ball.velocityX > 0) {
          ball.velocityX = -ball.velocityX;
        }
      } else if (ball.x <=
          0 + settings!.radius / 2 + settings!.lineWidth / 2) {
        if (ball.velocityX < 0) {
          ball.velocityX = -ball.velocityX;
        }
      }
      // 根據加速度,計算出小球當前的x值
      ball.x = ball.x + ball.velocityX * settings!.eachMovePixel;

      // 和計算x值一樣的原理, 計算出y的值
      // 當遇到邊界時,需要改變y加速度方向
      if (ball.y >=
          height - settings!.radius / 2 - settings!.lineWidth / 2) {
        if (ball.velocityY > 0) {
          ball.velocityY = -ball.velocityY;
        }
      } else if (ball.y <=
          0 + settings!.radius / 2 + settings!.lineWidth / 2) {
        if (ball.velocityY < 0) {
          ball.velocityY = -ball.velocityY;
        }
      }
      // 根據加速度,計算出小球當前的y值
      ball.y = ball.y + ball.velocityY * settings!.eachMovePixel;
    });
  });
複製程式碼

幀率資訊

繪製動畫的時候,為了瞭解Flutter的繪製效率,新增了幀率想關的資訊展示。

幀率資訊控制元件:

import 'dart:async';

import 'package:flutter/material.dart';

/// 幀率資訊控制元件
class FrameRateView extends StatefulWidget {
  @override
  _FrameRateViewState createState() => _FrameRateViewState();
}

class _FrameRateViewState extends State<FrameRateView> {
  int count = 0;
  int offsetTime = 0;
  int lastTime = DateTime.now().millisecondsSinceEpoch;
  int frameRate = 0;

  @override
  void initState() {
    super.initState();
    WidgetsBinding? widgetsBinding = WidgetsBinding.instance;
    // 第一幀的回撥
    widgetsBinding?.addPostFrameCallback((callback) {
      Timer.periodic(Duration(seconds: 1), (timer) {
        // 1秒計算一次幀率
        if (mounted) {
          setState(() {
            frameRate = count;
            count = 0;
          });
        }
      });
      // 持久幀的回撥
      widgetsBinding.addPersistentFrameCallback((callback) {
        if (mounted) {
          int nowTime = DateTime.now().millisecondsSinceEpoch;
          setState(() {
            count += 1;
            offsetTime = nowTime - lastTime;
            lastTime = nowTime;
          });
          widgetsBinding.scheduleFrame();
        }
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Text(
      "重新整理次數:$count\n每秒幀數:$frameRate\n每幀耗時:$offsetTime",
      style: TextStyle(color: Colors.white),
    );
  }
}

複製程式碼

最終效果

黑客帝國效果:

黑客帝國效果.gif

五彩蛛網效果:

五彩蛛網效果.gif

感想

以下是個人對Flutter2.0使用中的一些個人體會:

  1. 空指標特性:和Kotlin差不多,也是用感嘆號!問號?進行空指標的處理判斷。

  2. 程式碼巢狀:巢狀這一點,真正上手Flutter的開發者,應該疑慮會比較少。因為程式碼中編寫的巢狀,僅僅是介面元件的宣告,真正繪製的時候,是不存在巢狀問題的。Flutter卡頓的原因,基本不是因為程式碼巢狀,更多是不能合理正確的對StatelessWidgetStatefulWidget進行使用。

  3. 跨平臺:之前熟悉的是Android原生開發,對於iOS、Web、桌面端的瞭解,更多的是從原生平臺的程式碼框架去學習。平常工作內容更多的在資料處理展示上,比較少涉及到原生平臺特有的Api。在上手Flutter後,一套程式碼可以在多端上執行,確實大大拓寬了技術適用的廣度。目前來說,Android和iOS平臺基本滿足了業務要求,Web和桌面端目前效能和穩定性還沒達到期望值,還處在觀望期,希望後續官方能補齊短板。

倉庫地址

倉庫地址:github.com/SheepYang19…

感謝大家的閱讀,喜歡的話點個贊~

歡迎關注我的技術公眾號“懶洋君工作室”,不定期分享有趣、優質的技術文章~

相關文章