LPでよく見るあのアニメーションをFlutterで作る

7 min read読了の目安(約6400字

この記事はFlutter #1 Advent Calendar 2020の15日目の記事です.

時に,こんなアニメーションをよくLPなどで見かけることはありませんか?

(よく見かけると言いつつ具体例が出てこなかった…)
自分は暗号技術とかを使うサービスでありそうなイメージだと思ってます.

こういったアブストラクトなアニメーション,大体WebのCanvas APIやWebGL,あるいはCSSのプロならCSSだけで実装するかと思います(できるのか知らんけど).LPとかに使うくらいですからね.
今回は,使うかどうかは別としてFlutterでこれを実装する方法を解説していきます.

方向性

このアニメーションは,以下の要素から成り立っています.

  • ランダムな方向・速度で移動する点(パーティクル)
  • 点同士を結ぶ線分

とてもシンプルですね.

これを表現することを念頭に置き,まずはどんなデータを裏で持っておくか・どのようにFlutterで動かすかを考えます.

  1. 毎フレーム更新で表現したいのでFlutterのアニメーションシステムに乗っかる
  2. 主な要素は点群なので,Offsetと周辺情報(速度や方向)をセットで持つクラスをListで管理すればいい
  3. 線分はあくまで点と点をつなぐだけなのでデータで持っておく必要はない?
  4. 線分の薄さを描画タイミングの状態(ここでは2点間の距離)で制御したいのでそのための情報は必要

更に,今回はおまけの縛りとして できる限り外部パッケージ依存なし で実装することを考えました.最終的に一つだけ使いましたが…

リポジトリ

今回作ったもののソースコードがこちらです.
FlutterのバージョンはWebで動かすのを見越してbetaチャンネルのバージョンを使いました.しかし特に新機能などを使っているわけではないのでstableでも動きます.

https://github.com/Kurogoma4D/flutter_tech_particle

解説

パーティクル自体

パーティクルそのものの定義です.なるべく外部パッケージを使わないようにと思っていましたが,実装途中にcopyWithメソッドと==オペレーターを使いたくなってしまい,結局freezedを導入しました.使わなくてもできます.

パーティクルそのものの内部情報として,座標position,速度velocity,近い距離にあるパーティクル情報nearbyParticles,パーティクルの移動する方向の角度angleを持つことにします.また,nearbyParticlesに関しては,パーティクル情報そのままではなく,対象のパーティクルの座標positionと,元のパーティクルと対象のパーティクルの距離distanceをセットにしたDistanceを持っておきます(命名が良くない🤔).

particle.dart

abstract class Particle with _$Particle {
  const factory Particle({
    Offset position,
    double velocity,
    List<Distance> nearbyParticles,

    /// パーティクルの移動する方向.
    /// unit: radian
    double angle,
  }) = _Particle;
}


abstract class Distance with _$Distance {
  const factory Distance({
    Offset position,
    double distance,
  }) = _Distance;
}

パーティクル管理

パーティクルを管理するクラスで現在のパーティクル情報を保持・状態更新を行います.

大まかな処理の流れとして,

  1. Stateの初期化時に定数MAX_PARTICLES分パーティクルを生成
  2. 更新タイミングで全パーティクルに対して,以下の処理を行う
    1. それぞれの持つベクトル情報を用いて座標の更新をする
    2. もし更新後の座標が画面の範囲外だった場合,新しいパラメータをもったパーティクルを生成する
    3. それぞれを中心とした半径NEARBY_RADIUSの円を想定,その範囲内に入るパーティクルをDistanceとして保存する

といったことをやっています.

particle_state.dart
  // ...省略(初期化・パーティクル生成)

  Particle _advancePosition(Particle particle) {
    final origin = particle.position;
    final deltaPosition =
        Offset(math.cos(particle.angle), math.sin(particle.angle)) *
            particle.velocity;
    return particle.copyWith(position: origin + deltaPosition);
  }

  void update() {
    for (int index = 0; index < MAX_PARTICLES; index++) {
      // パーティクルの座標更新
      particles[index] = _advancePosition(particles[index]);

      // 画面範囲と比較,範囲外なら新しいパーティクル生成
      if (!screenSize.contains(particles[index].position)) {
        particles[index] = _generateNewParticle();
      }
    }

    for (int index = 0; index < MAX_PARTICLES; index++) {
      // 自分以外のパーティクルを抽出
      final targets = particles.where((e) => e != particles[index]);

      // 自分とそれ以外のパーティクルとの距離を計算
      final distances = targets.map((e) => Distance(
            position: e.position,
            distance: e.position.distanceToOther(particles[index].position),
          ));

      // NEARBY_RADIUSより距離が近いパーティクルをnearbyParticlesとして保存
      particles[index] = particles[index].copyWith(
        nearbyParticles:
            distances.where((e) => e.distance < NEARBY_RADIUS).toList(),
      );
    }
  }

描画

上記のように管理している状態を,CustomPainterを使って描画します.

先述のようにFlutterのアニメーションシステムに乗っかるため,StatefulWidgetAnimationControllerを定義しておきます.また,パーティクルの状態インスタンスも合わせて持っておきます.

particle_view.dart
class _ParticleViewState extends State<ParticleView>
    with SingleTickerProviderStateMixin {
  AnimationController baseAnimationController;
  ParticleState state;

  
  void initState() {
    baseAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(days: 1),
    )
      ..addListener(() => state?.update())
      ..forward();

    state = ParticleState(screenSize: widget.screenSize);
    super.initState();
  }

Durationが1日という長い時間のアニメーションを定義しておくことで,とりあえず表示中は常時更新されるということにしておきます.
そして毎フレーム実行されるリスナーに先述のParticleState#updateを仕込むことで,毎フレームパーティクルの状態が更新されるようになります.
このAnimationControllerAnimatedBuilderを使い,Widgetのリビルドに作用させます.

particle_view.dart
    // ...省略
    AnimatedBuilder(
        animation: baseAnimationController,
        builder: (context, _) => CustomPaint(
            size: widget.screenSize,
            painter: _ParticlePainter(
              particles: state.particles,
            ),
        ),
    ),

実際にパーティクルを描画するのはCustomPainterです.この中にパーティクル情報を渡してすべてのパーティクル及び近いパーティクルを結ぶ線分を描画してもらいます.

particle_view.dart
class _ParticlePainter extends CustomPainter {
  final List<Particle> particles;
  _ParticlePainter({this.particles});

  static final _particlePaint = Paint()..color = Colors.white;

  
  void paint(Canvas canvas, Size size) {
    // すべてのパーティクルを描画
    for (final particle in particles) {
      // 速度に応じた大きさの円をパーティクルとして描画
      final radius = particle.velocity.clamp(0.4, MAX_VELOCITY) * 2.0;
      canvas.drawCircle(particle.position, radius, _particlePaint);

      // 今描画したパーティクルに近いパーティクルについて
      for (final nearby in particle.nearbyParticles) {
        // その距離を元に線分の不透明度を設定
        final linkPaint = Paint()
          ..color =
              Colors.white.withOpacity(1.0 - (nearby.distance / NEARBY_RADIUS))
          ..strokeWidth = 1.0;

        // 自身と対象のパーティクルとの間に線分を描画
        canvas.drawLine(particle.position, nearby.position, linkPaint);
      }
    }
  }

  
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

そして描画されたものが最初に貼っていたgifです.意外とあっさりできましたね🥳

おわりに

前の記事に引き続き,実用性があるのか無いのかよくわからない内容の記事を書いてしまいました.
ただ,CustomPainterに関しては結構業務でも使えたりするので,もし学習のきっかけになったとしたら幸いです.結構色んなことができるので,ドキュメントを漁ってみるのもいいかと思います.

また,Flutter Webでなにか作りたいとなった時に,こういったビジュアルを作りつつMouseRegionを使ったインタラクションを仕込むなんてこともできてしまいます.
へぶんさんのFlutter で自作マウスカーソルを表示するみたいな感じですね.
是非お試しあれ.

Keep Fluttering!