blog

テーブル・ピンボールにFlutterを使う?描画APIについて話す

この記事はFlutterでCanvasとAPIを使う例です。 画面を長押しすると、ボールのスタート/ストップが自動的に切り替わります。 CanvasとAPI。 方向移動の位置更新。 ユーザージェスチャ...

Aug 7, 2020 · 9 min. read
シェア

この記事はFlutterのCanvasとCustomPaint APIの使用例です。

最初に達成すべき効果を見てください:

動画のデモと合わせて、最終的なゴールを以下に示します:

  1. プログラムが実行されると、小さなボールが表示されます;
  2. プログラムを開始するたびに、ボールの大きさ、色、位置がランダムに変わります;
  3. 画面をクリックするとボールの色が変わります;
  4. 画面をダブルタップすると、ボールの動きを一時停止/再開できます;
  5. 画面を長押しすると、ボールの色が自動的に変わり始めます。

主な使用技術:CanvasとCustomPaint API。

プラットフォーム:Android、iOS

ソースコードのアドレス

Github

機能分解

前回の記事で挙げた6つの達成目標を解体することから始め、それらを達成するために必要なことは明らかです:

  1. ランダムカラージェネレーター;
  2. ランダムポジションジェネレーター;
  3. ランダムサイズジェネレーター;
  4. 小さなボールの描画ロジック;
  5. ボールの動きの論理:
    • 領域の決定
    • 初期動作方向生成;
    • 方向移動位置更新装置。
  6. ユーザージェスチャーのリスナー。

関数の実装

次に、機能分解でリストアップされた6つの具体的な機能を段階的に実装します。

ランダムカラージェネレーター

ランダムなカラージェネレータはアプリケーションの起動、画面のクリック、自動カラーチェンジに使われます。Flutterでは、Colorクラスで赤、緑、青、透明度をそれぞれ0〜255の値で定義することができます。 透明度については、0は完全に透明、255は完全に不透明を意味します。

ランダムな値については、Random クラスを使用して 0 ~ 255 のランダムな整数を生成します。

ランダム・カラー・ジェネレータは主に上記の2つのクラスを実装するために使用されます:

Color _color = Color.fromARGB(0, 0, 0, 0);
// ボールの色を変える
void changeColor() {
	_color = Color.fromARGB(255, Random().nextInt(255), Random().nextInt(255),Random().nextInt(255));
}

ランダムポジションジェネレーター

ランダム位置発生器はプログラム起動時に使用されます。ランダムな位置を生成するには、やはりRandomクラスを使う方法ですが、乱数値の範囲に注意してください。通常、ボールが画面内に見える位置が必要なので、ボールの初期位置のx軸とy軸の座標を表す2つの乱数を生成する必要があります。この座標値は、それぞれ画面の水平方向と垂直方向の寸法よりも小さい値です。もちろん、どちらも0より大きくなければなりません。

また、画面の横幅と縦幅もそれぞれ求める必要があります。

そこで、具体的なコードは次のように実装します:

[画面の幅と高さを取得]

double screenX, screenY;
@override
Widget build(BuildContext context) {
	screenX = MediaQuery.of(context).size.width;
	screenY = MediaQuery.of(context).size.height;
	...
}

[ランダムな場所を生成]

double _x = 0, _y = 0;
// ボールの初期位置とサイズを生成する。
void generateBall() {
	_x = Random().nextDouble() * screenX;
	_y = Random().nextDouble() * screenY;
}

ランダムサイズジェネレーター

ランダムサイズ生成器はプログラム起動時に使用されます。2つの乱数値の生成が完了したので、サイズに関しては簡単です。乱数サイズと乱数位置の両方がプログラム起動時に呼び出され、操作されるオブジェクトは小さなボールなので、両方の実装はgenerateBall()メソッドに配置されます。最終的なコードは以下のようになります:

double _x = 0, _y = 0, _size = 0;
// ボールの初期位置とサイズを生成する。
void generateBall() {
 _size = Random().nextDouble() * (screenY - screenX).abs();
 _x = Random().nextDouble() * screenX;
 _y = Random().nextDouble() * screenY;
}

ボールドローイング・ロジック

インターフェイスにボールを描くには、CustomPaintコンポーネントを使う必要があります。CustomPaintコンポーネントにはCustomPainterのインスタンスが必要です。ボールの描画は主にCustomPainterを継承したクラスで行われます。コードを直接見てください:

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class Ball extends CustomPainter {
 Paint _paint;
 double _x, _y, _size;
 Ball(double x, double y, double size, Color color) {
 _paint = new Paint();
 _paint.isAntiAlias = true;
 _paint.color = color;
 this._x = x;
 this._y = y;
 this._size = size;
 }
 @override
 void paint(Canvas canvas, Size size) {
 canvas.drawOval(Rect.fromCenter(center: Offset(_x, _y), width: _size, height: _size), _paint);
 }
 @override
 bool shouldRepaint(CustomPainter oldDelegate) {
 return oldDelegate != this;
 }
}

上のコードを読むと、Ballクラス全体ではコンストラクタ・メソッドに加えて2つのオーバーライド・メソッドがあるだけで、非常にシンプルであることがわかります。

コンストラクタ・メソッドでは、_paintオブジェクトが初期化されます;

paint()メソッドでは、canvasオブジェクトのdrawOvalメソッドを呼び出して円を描きます;

shouldRepaint()メソッドは、レイアウトが更新されたときに再描画する必要があるかどうかを示します。

上記のコードをball.dartとして保存します。

位置、色、サイズに固定値がないことに注意してください。これは、このクラスが「円を描く」ことだけを担当しているからで、どのような円を描くかは、クラスのユーザー、つまりmain.dartの定義に任されています。

main.dartで、アプリをフルスクリーンに設定し、フルスクリーンサイズのCustomPaintコンポーネントを追加し、その中にBallオブジェクトを配置します。

@override
Widget build(BuildContext context) {
 screenX = MediaQuery.of(context).size.width;
 screenY = MediaQuery.of(context).size.height;
 return Scaffold(
 body: GestureDetector(
 child: Container(
 width: double.infinity,
 height: double.infinity,
 child: CustomPaint(painter: Ball(_x, _y, _size, _color))),
 onTap: () {
 	// ボールの色を変える
 	changeColor();
 },
 onDoubleTap: () {
 	//  /移動を再開する
 	_keep_move = !_keep_move;
 },
 onLongPress: () {
 	// ボールの色を自動的に変える
 	_auto_change_color = !_auto_change_color;
 },
 ));
}

上記のコードでは、GestureDetectorコンポーネントがユーザークリックイベントの受信を担当し、_keep_move、_auto_change_colorはBoolean型の変数で、ボールの移動とauto_change_color関数のスイッチになります。

次に、initState()メソッドで呼び出されたランダム位置ジェネレータ、ランダムサイズジェネレータ、ランダムカラージェネレータに値_x、_y、_size、_colorが代入されます。

@override
void initState() {
 super.initState();
 WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
 generateBall();
 changeColor();
 calculateMoveAngle();
 startMove();
 });
}

ここでは、calculateMoveAngle() メソッドと startMove() メソッドが、それぞれ初期モーション方向ジェネレーターと、モーションを開始し定期的に UI を更新するメソッドに対応します。これら2つのメソッドに加えて、今アプリケーションを実行すると、画面に静的なブロブが表示され、アプリケーションを再実行するたびにブロブのスタイルと位置が変化するのが見えるはずです。

次に、ブロブを動かしてみましょう!

ボールの動きのロジック

ボールを正確に動かすには、まずランダムな移動方向を生成すること、次に60FPSの周波数で5ピクセルずつ移動方向を進めること、最後に境界判定に注意してボールが画面の端に達したときに正しく舵を切ること。

以下、1つずつ実装していきます。

初期動作方向ジェネレーター

ランダムな方向なので、平面上の360度の範囲であればどの角度でも可能です。したがって、ここではまず0~360の範囲の値を生成する必要があります。次に、三角関数と進行方向の速度に基づいて、水平座標と垂直座標の速度を計算します。ピタゴラスの定理です。

double _step_x, _step_y, _angle;
// ボールの初動角度を計算する
void calculateMoveAngle() {
 _angle = Random().nextDouble() * 360;
 _step_x = sin(_angle) * _speed;
 _step_y = cos(_angle) * _speed;
}

ここでは、移動速度を三角形の斜辺とみなし、水平・垂直座標での移動速度を三角形の直角辺とみなします。私の記憶が正しければ、すべて中学校の幾何学で、理解するのは難しくないでしょう。

方向移動位置アップデータ

前述の通り、インターフェイスは60FPSのリフレッシュレートで更新されるため、ボールの位置は約16msごとに更新されます。これは、ボールの位置が16msごとに更新されることを意味します。ボールの動きによってのみ、インターフェイスが「更新されている」と感じさせることができるからです。このステップでは、Timerクラスを使います。そして、アップデータはinitState()メソッドで呼び出され、プログラムが始まるとボールがすぐに動くようにします。

// 動き始める
void startMove() {
 Timer.periodic(Duration(milliseconds: 16), (timer) {
 moveBall();
 setState(() {});
 });
}
// ボールを動かす
void moveBall() {
 _x += _step_x;
 _y += _step_y;
}

この時点まで、ボールはランダムな方向に動き出すことができました。しかし、すぐに、ボールは画面の外に移動します。

境界の決定

明らかに、ボールが一歩前進するたびに、ボールがスクリーン範囲外に移動しないようにスクリーン境界判定を行う必要があります。そして、その境界判定をmoveBall()メソッドで実装するのが最も適切だと思われます。

ボールの移動のルールを簡単にまとめると、ボールが画面の端に移動したら、逆方向に移動させればいいのです。例えば、ボールが3の速度で移動して画面の右端に触れ、次はやはり3の速度で移動して画面の左端に向かいます。

これは水平方向にも垂直方向にも当てはまります。

したがって、境界判定ロジックは次のようになります:

// 便利な判定でボールを動かす
void moveBall() {
 if (_x >= screenX || _x <= 0) {
 _step_x = 0 - _step_x;
 }
 _x += _step_x;
 if (_y >= screenY || _y <= 0) {
 _step_y = 0 - _step_y;
 }
 _y += _step_y;
}

ユーザー・ジェスチャー・リスナー

最後に、ユーザーのジェスチャーと関連するブール変数を使って、ボールの位置が更新されるたびに色が変わり、動きが一時停止するようにします。

moveBall() メソッドの修正を続けます:

// 便利な判定でボールを動かす
void moveBall() {
 if (_keep_move) {
 if (_x >= screenX || _x <= 0) {
 _step_x = 0 - _step_x;
 }
 _x += _step_x;
 if (_y >= screenY || _y <= 0) {
 _step_y = 0 - _step_y;
 }
 _y += _step_y;
 if (_auto_change_color) {
 changeColor();
 }
 }
}

この時点で、プログラムは完全に実装されています。以下にmain.dartの完全なコードを載せておきます:

import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'ball.dart';
void main() {
 runApp(MyApp());
}
class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
 SystemChrome.setEnabledSystemUIOverlays([]);
 return MaterialApp(
 title: 'Flutter Demo',
 theme: ThemeData(
 primarySwatch: Colors.blue,
 visualDensity: VisualDensity.adaptivePlatformDensity,
 ),
 home: BounceBall(),
 );
 }
}
class BounceBall extends StatefulWidget {
 @override
 _BounceBallState createState() => _BounceBallState();
}
class _BounceBallState extends State<BounceBall> {
 final double _speed = 5;
 double _x = 0, _y = 0, _size = 0;
 double _step_x, _step_y, _angle;
 Color _color = Color.fromARGB(0, 0, 0, 0);
 bool _auto_change_color = false;
 bool _keep_move = true;
 double screenX, screenY;
 @override
 void initState() {
 super.initState();
 WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
 generateBall();
 changeColor();
 calculateMoveAngle();
 startMove();
 });
 }
 @override
 Widget build(BuildContext context) {
 screenX = MediaQuery.of(context).size.width;
 screenY = MediaQuery.of(context).size.height;
 return Scaffold(
 body: GestureDetector(
 child: Container(
 width: double.infinity,
 height: double.infinity,
 child: CustomPaint(painter: Ball(_x, _y, _size, _color))),
 onTap: () {
 // ボールの色を変える
 changeColor();
 },
 onDoubleTap: () {
 //  /移動を再開する
 _keep_move = !_keep_move;
 },
 onLongPress: () {
 // ボールの色を自動的に変える
 _auto_change_color = !_auto_change_color;
 },
 ));
 }
 // 動き始める
 void startMove() {
 Timer.periodic(Duration(milliseconds: 16), (timer) {
 moveBall();
 setState(() {});
 });
 }
 // ボールの色を変える
 void changeColor() {
 _color = Color.fromARGB(255, Random().nextInt(255), Random().nextInt(255),
 Random().nextInt(255));
 }
 // ボールの初期位置とサイズを生成する。
 void generateBall() {
 _size = Random().nextDouble() * (screenY - screenX).abs();
 _x = Random().nextDouble() * screenX;
 _y = Random().nextDouble() * screenY;
 }
 // ボールの初動角度を計算する
 void calculateMoveAngle() {
 _angle = Random().nextDouble() * 360;
 _step_x = sin(_angle) * _speed;
 _step_y = cos(_angle) * _speed;
 }
 // 便利な判定でボールを動かす
 void moveBall() {
 if (_keep_move) {
 if (_x >= screenX || _x <= 0) {
 _step_x = 0 - _step_x;
 }
 _x += _step_x;
 if (_y >= screenY || _y <= 0) {
 _step_y = 0 - _step_y;
 }
 _y += _step_y;
 if (_auto_change_color) {
 changeColor();
 }
 }
 }
}

一緒にこのプログラムを実行しましょう!

Read next

Springブートに関するトランザクション実践チュートリアル

この投稿は主にチュートリアルの使い方についてです。 注:プロジェクトを直接入手したい場合は、直接下にジャンプしてリンクからプロジェクトコードをダウンロードしてください。 Springでは、トランザクションを実装する方法として、プログラム的トランザクション管理と宣言的トランザクション管理の2つの方法があります。 プログラムによるトランザクション管理:Tr...

Aug 7, 2020 · 21 min read