LPでよく見るあのアニメーションをFlutterで作る
この記事はFlutter #1 Advent Calendar 2020の15日目の記事です.
時に,こんなアニメーションをよくLPなどで見かけることはありませんか?
(よく見かけると言いつつ具体例が出てこなかった…)
自分は暗号技術とかを使うサービスでありそうなイメージだと思ってます.
こういったアブストラクトなアニメーション,大体WebのCanvas APIやWebGL,あるいはCSSのプロならCSSだけで実装するかと思います(できるのか知らんけど).LPとかに使うくらいですからね.
今回は,使うかどうかは別としてFlutterでこれを実装する方法を解説していきます.
方向性
このアニメーションは,以下の要素から成り立っています.
- ランダムな方向・速度で移動する点(パーティクル)
- 点同士を結ぶ線分
とてもシンプルですね.
これを表現することを念頭に置き,まずはどんなデータを裏で持っておくか・どのようにFlutterで動かすかを考えます.
- 毎フレーム更新で表現したいのでFlutterのアニメーションシステムに乗っかる
- 主な要素は点群なので,
Offset
と周辺情報(速度や方向)をセットで持つクラスをList
で管理すればいい - 線分はあくまで点と点をつなぐだけなのでデータで持っておく必要はない?
- 線分の薄さを描画タイミングの状態(ここでは2点間の距離)で制御したいのでそのための情報は必要
更に,今回はおまけの縛りとして できる限り外部パッケージ依存なし で実装することを考えました.最終的に一つだけ使いましたが…
リポジトリ
今回作ったもののソースコードがこちらです.
FlutterのバージョンはWebで動かすのを見越してbeta
チャンネルのバージョンを使いました.しかし特に新機能などを使っているわけではないのでstable
でも動きます.
解説
パーティクル自体
パーティクルそのものの定義です.なるべく外部パッケージを使わないようにと思っていましたが,実装途中にcopyWith
メソッドと==
オペレーターを使いたくなってしまい,結局freezedを導入しました.使わなくてもできます.
パーティクルそのものの内部情報として,座標position
,速度velocity
,近い距離にあるパーティクル情報nearbyParticles
,パーティクルの移動する方向の角度angle
を持つことにします.また,nearbyParticles
に関しては,パーティクル情報そのままではなく,対象のパーティクルの座標position
と,元のパーティクルと対象のパーティクルの距離distance
をセットにしたDistance
を持っておきます(命名が良くない🤔).
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;
}
パーティクル管理
パーティクルを管理するクラスで現在のパーティクル情報を保持・状態更新を行います.
大まかな処理の流れとして,
- Stateの初期化時に定数
MAX_PARTICLES
分パーティクルを生成 - 更新タイミングで全パーティクルに対して,以下の処理を行う
- それぞれの持つベクトル情報を用いて座標の更新をする
- もし更新後の座標が画面の範囲外だった場合,新しいパラメータをもったパーティクルを生成する
- それぞれを中心とした半径
NEARBY_RADIUS
の円を想定,その範囲内に入るパーティクルをDistance
として保存する
といったことをやっています.
// ...省略(初期化・パーティクル生成)
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のアニメーションシステムに乗っかるため,StatefulWidget
にAnimationController
を定義しておきます.また,パーティクルの状態インスタンスも合わせて持っておきます.
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
を仕込むことで,毎フレームパーティクルの状態が更新されるようになります.
このAnimationController
をAnimatedBuilder
を使い,Widgetのリビルドに作用させます.
// ...省略
AnimatedBuilder(
animation: baseAnimationController,
builder: (context, _) => CustomPaint(
size: widget.screenSize,
painter: _ParticlePainter(
particles: state.particles,
),
),
),
実際にパーティクルを描画するのはCustomPainter
です.この中にパーティクル情報を渡してすべてのパーティクル及び近いパーティクルを結ぶ線分を描画してもらいます.
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!
Discussion