Flutterで雪を降らせる「雪の降るアニメーションの実装」

この記事はFlutter Flutter #2 Advent Calendar 2021 24日目の記事です。
せっかくクリスマスイブですし、クリスマスっぽいものを投稿しようと思い、雪を降らせる演出を考えてみました。

雪を作成する

大分迷走しましたが、以下のようになりました。
1フレームごとに、新しいWidgetを作ってます。1フレームで100くらいのWidgetを再作成しています。一応Keyは割り当てているので、Elementの方はうまく再利用しているに違いない、と期待して。毎秒30回再作成しても、だいたい毎秒60フレームは表示しているので、そこまで遅くなる処理ではありませんでした。
半透明の白い丸を二つ作って、雪っぽくしてます。外側は大きめで薄めにして、内側は小さめではっきりと表示するようにしてます。そのままでは内側が外側の左上に合わさるので、paddingで真ん中に来るように調整してます。
ちなみに Animationは使っておりません。使用しても、表示があまり良くならなかった(私の設定が甘いだけかも知れませんが)のと、毎秒60フレームが出なくなったからです。
オブジェクト指向的には、このWidget自身に落下するメソッドを追加しましたが、うまく更新されませんでした。座標が変わるのでStatefuleWidgetにしたため、雪のリスト内で参照が変わらず、再描画してくれなかったのかな、と考えております。

class Snow extends StatelessWidget {
  final double size;
  static const rate = 0.7;

  double x;
  double y;

  Snow({
    required this.size,
    required this.x,
    required this.y,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Positioned(
      top: y,
      left: x,
      child: Stack(
        children: [
          Padding(
            padding: EdgeInsets.all(size * (1 - rate) / 2),
            child: Container(
              width: size * rate,
              height: size * rate,
              decoration: BoxDecoration(
                color: Colors.white.withOpacity(0.7),
                shape: BoxShape.circle,
              ),
            ),
          ),
          Container(
            width: size,
            height: size,
            decoration: BoxDecoration(
              color: Colors.white.withOpacity(0.4),
              shape: BoxShape.circle,
            ),
          ),
        ],
      ),
    );
  }
}

雪の位置を保持するクラス

雪関連は3つのクラスがあります。先ほどの雪の描画用のWidget(Snow)、この雪の位置を保持するクラス(SnowData)、そして雪の移動を管理するロジッククラス(SnowLogic)です。
雪の位置を保持するクラスはFreezedを使用して作成しました。Immutableなクラスを作成するのに便利です。導入方法は以下に記載してますので、参照して頂けると幸いです。
Flutter freezed のメモ【知っている人向け】

雪一つに対して、一つのSnowDataのインスタンスがつきます。自身のkeyと、管理する雪のkeyを保持します。また雪のXY座標と大きさを保持します。

@freezed
class SnowData with _$SnowData {
  const factory SnowData({
    required Key key,
    required Key snowKey,
    required double x,
    required double y,
    required double size,
  }) = _SnowData;
}

雪を管理するクラス

画面外まで落ちた雪は、再度上から降ってくるようにしています。そのため、画面サイズをコンストラクタで受け取ります。
generateにて、新しい雪のデータを作っています。作成するときに、場所や大きさが分かるように乱数を使用してます。Random.nextDouble()で0-1の値が取得できます。
fallにて、雪の落下を表現してます。y座標は今の座標に、一直線にならないように乱数を入れて計算してます。
x座標も今の座標も考慮してますが、ただ足すだけだと雪が全部右側(X+の方向)に飛んでいきます。そのため、0.5を引いて、平均0になるように乱数を修正します。

class SnowLogic {
  Random _random = Random();

  final Size displaySize;
  SnowLogic(this.displaySize);

  SnowData generate(
    int index,
    double minSize,
    double maxSize,
  ) {
    return SnowData(
      key: ObjectKey('snow_$index'),
      snowKey: ObjectKey('snowKey_$index'),
      x: _random.nextDouble() * displaySize.width,
      y: _random.nextDouble() * displaySize.height,
      size: (maxSize - minSize) * _random.nextDouble() + minSize,
    );
  }

  SnowData fall(SnowData snow, double speed) {
    double x = snow.x + 0.3 * speed * (_random.nextDouble() - 0.5);
    double y = snow.y + speed / 3 + 2 * speed * _random.nextDouble() / 3;
    y = displaySize.height < y ? -5 : y;

    var newSnow = snow.copyWith(x: x, y: y);
    return newSnow;
  }
}

メイン

画面のサイズを取得したいので、MateriallAppの前に画面取得のメソッドを書きました。contextはMaterialApp内でないと動作しないので、諦めました。代わりにscaffoldの大きさを取得することにしました。
initStateの実行時では、contextを取得すると初期化前でエラーが発生しました。ただ、buildの中で毎回サイズを取得するのも良くないな、と調べていたら、WidgetsBinding.instance?.addPostFrameCallback というものが見つかりました。Widgetのbuild完了直後に1回だけ実行できるそうです。その中で、画面サイズを取得して、雪のデータを作るようにしました。
そして、タイマーを起動して、定期的に雪の座標を書き換えて、雪のWidgetを上書きしていきます。

class _MyHomePageState extends State<MyHomePage> {
  List<Snow> snows = [];
  List<SnowData> snowData = [];
  Timer? _timer;

  late SnowLogic logic;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance?.addPostFrameCallback((callback) {
      RenderBox scaffold =
          _keyScaffold.currentContext?.findRenderObject() as RenderBox;

      logic = SnowLogic(scaffold.size);

      for (int i = 0; i < NUMBER_OF_SNOW; i++) {
        snowData.add(logic.generate(i, MIN_SIZE_OF_SNOW, MAX_SIZE_OF_SNOW));
      }

      _timer =
          Timer.periodic(Duration(milliseconds: FRAME_RATE.ceil()), (timer) {
        List<Snow> newSnows = [];

        for (int i = 0; i < snowData.length; i++) {
          snowData[i] = logic.fall(snowData[i], 10);
        }

        for (var snow in snowData) {
          Snow newSnow = Snow(
            key: snow.snowKey,
            x: snow.x,
            y: snow.y,
            size: snow.size,
          );
          newSnows.add(newSnow);
          // print('${snow.x} ${snow.y}');
        }

        setState(() {
          snows = newSnows;
        });
      });
    });
  }

せっかくですので、背景をクリスマスっぽいものをと、USJのハリー・ポッターのお城の写真を背景に貼り付けました。その上に、雪を重ねて表示するようにしました。これにて完成です!

 @override
  Widget build(BuildContext context) {
    //print('build');
    return Scaffold(
      key: _keyScaffold,
      body: Stack(
        children: [
          Container(
            decoration: BoxDecoration(
              color: Colors.black,
              image: DecorationImage(
                image: AssetImage(Assets.imgp4604.assetName),
                fit: BoxFit.cover,
              ),
            ),
          ),
          ...snows
        ],
      ),
    );
  }
}

ハマったところ

最初の1回目はちゃんと雪が描画されるが、それ以降は動かない、というFlutterを始めたばかりの時のミスをしました。
最初は雪のWidgetをStatelessWidgetのまま、XY座標をつけて動かそうとしてました。そりゃ動かんわな、とStatefulWidgetにしましたが、やっぱり動きませんでした。リストのせいで、更新がうまく伝播しなかったのかぁ、と考えております。StatelessWidgetを再作成して、描画してくれるようになりました。
雪を描画するWidget、雪のデータクラス、座標を修正するクラスを分けた方がよいのかな、と考えてます。それでStatelessWidgetで毎回再作成すれば、ひとまず更新を伝播してくれるだろう、と。わりと分業はうまくできていると思います。

ソースは以下に置いておきます

https://github.com/fluttersalon/snow
参考にして頂けると嬉しいです。雪と言えば、と最初使ってましたが、クリスマスっぽくないので没になった白川郷の写真もあります(笑)

参考

FlutterでWidgetの位置とサイズを取得する
WidgetsBinding.instance.addPostFrameCallback の説明が助かりました

まとめ

ということで、クリスマスっぽいものを作ってみました。雪の落ち方が少し不自然な気もしますが、気にしてても仕方ないので、終わります。100と多めのWidgetを再作成してますが、意外と16msの壁は越えていません。Fltuter、速いですね。
それではみなさま、メリークリスマス!、、、まあ、私は仕事で出張ですけどね(笑)