この記事は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、速いですね。
それではみなさま、メリークリスマス!、、、まあ、私は仕事で出張ですけどね(笑)