用Flutter做桌上彈球?聊聊繪圖(Canvas&CustomPaint)API
阿新 • • 發佈:2020-07-30
本文是Flutter中Canvas和CustomPaint API的使用例項。
首先看一下我們要實現的效果:
![實現效果](https://img2020.cnblogs.com/blog/1595922/202007/1595922-20200730152455349-1226065709.gif)
結合動圖演示,列出最終**目標如下:**
1. 在程式執行後,顯示一個小球;
2. 每次程式啟動後,小球的樣式均發生隨機性變化,體現在大小、顏色和位置三點;
3. 小球執行的規律參考桌球或三維彈球遊戲;
4. 單擊螢幕,小球變色;
5. 雙擊螢幕,小球暫停/恢復運動;
6. 長按螢幕,小球開始/停止自動變色。
**運用的主要技術點:**
Canvas和CustomPaint API。
**執行平臺:**
Android、iOS
**原始碼地址:**
[Github](https://github.com/wh1990xiao2005/flutter_bounceball)
[Gitee](https://gitee.com/wh1990xiao2005/flutter_bounceball)
-----
## 功能拆解
首先拆解前文中所列出的6個實現目標,顯而易見,要實現它們,我們需要:
1. 隨機顏色生成器;
2. 隨機位置生成器;
3. 隨機尺寸生成器;
4. 小球繪製邏輯;
5. 小球運動邏輯:
- 邊界判定;
- 初始運動方向生成器;
- 定向移動位置更新器。
6. 使用者手勢監聽器。
## 功能實現
接下來,我們逐步實現功能拆解中所列舉的6個具體功能。
### 隨機顏色生成器
**隨機顏色生成器在程式啟動、單擊螢幕和自動變色中使用。**
在Flutter中,我們可以通過Color類對紅、綠、藍和透明度分別定義,來定義某個唯一的顏色,數值範圍是0-255。對於透明度,0表示完全透明,255表示完全不透明。
對於隨機數值,我們使用Random類生成0-255之間的隨機整數。
隨機顏色生成器則主要使用上述兩個類來實現,具體程式碼片段如下:
```c
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軸座標。座標值分別小於螢幕橫向尺寸和縱向尺寸。當然,它們都要大於0。
另外,我們還需要分別獲取螢幕的寬高。
因此,具體程式碼實現如下:
[獲取螢幕寬高]
```c
double screenX, screenY;
@override
Widget build(BuildContext context) {
screenX = MediaQuery.of(context).size.width;
screenY = MediaQuery.of(context).size.height;
...
}
```
[生成隨機位置]
```c
double _x = 0, _y = 0;
// 生成小球初始位置和大小
void generateBall() {
_x = Random().nextDouble() * screenX;
_y = Random().nextDouble() * screenY;
}
```
### 隨機尺寸生成器
**隨機尺寸生成器在程式啟動時使用。**
完成了之前兩種隨機值的生成,到了尺寸這裡,就很輕車熟路了。由於隨機尺寸和隨機位置都在程式啟動時呼叫,且操作物件都是小球,我們將其實現都放在generateBall()方法中。最終程式碼如下:
```c
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的類中。我們直接看程式碼:
```c
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類除了構造方法外,只有兩個override的方法,可以說是很簡單了。
在構造方法中,我們初始化了_paint物件,它是可以看做是“畫筆”;
在paint()方法中,我們呼叫canvas物件的drawOval方法畫圓,表示小球。canvas可以看做是“畫板”;
shouldRepaint()方法表示在重新整理佈局的時是否需要重繪,只有在返回true時會發生重繪,這裡我們讓程式自行判斷就可以了。
我們將上述程式碼儲存為ball.dart備用。
注意,這裡面無論是位置、顏色還有尺寸,都沒有寫固定的值。是因為該類只負責“畫圓”,而具體畫什麼樣的圓,則交給該類的使用者來定義,也就是main.dart。
在main.dart中,我們將App設定為全屏,並新增全屏尺寸的CustomPaint元件,元件內放置Ball物件。
```c
@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都是布林型別變數,是小球移動和自動變色功能的開關。
接下來,我們在initState()方法中呼叫之前的隨機位置生成器、隨機尺寸生成器和隨機顏色生成器,賦值_x、_y、_size和_color。
```c
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
generateBall();
changeColor();
calculateMoveAngle();
startMove();
});
}
```
這裡面,calculateMoveAngle()和startMove()方法分別對應初始運動方向生成器以及開始運動並定期更新UI的方法。除了這兩個方法外,如果現在執行程式的話,應該可以看到一個靜態的小球出現在螢幕上了,並且隨著每次重新執行程式,小球的樣式和位置都將發生變化。
接下來,我們就來讓小球動起來吧!
### 小球運動邏輯
要讓小球準確無誤地運動,我們需要遵循以下步驟:首先生成一個隨機的運動方向;然後以60FPS的頻率,每次在運動方向上前進5個畫素的步長(當然,你可以自定義);最後還要注意邊界判定,在小球到達螢幕邊緣時正確轉向。
下面我們逐個實現。
#### 初始運動方向生成器
既然是隨機方向,那麼平面上360度範圍內任何一個角度都有可能。因此,我們這裡需要先生成0-360範圍內的值。然後根據三角函式和運動方向的速度,計算出橫、縱座標的速度。其實很簡單,就是勾股定理。
```c
double _step_x, _step_y, _angle;
// 計算小球初始移動角度(方向)
void calculateMoveAngle() {
_angle = Random().nextDouble() * 360;
_step_x = sin(_angle) * _speed;
_step_y = cos(_angle) * _speed;
}
```
我們這裡把運動速度(_speed)看做是三角形的斜邊,橫、縱座標的移動速度(_step_x、_step_y)看做是三角形的直角邊即可。沒記錯的話,都是初中幾何知識,不會很難理解。
#### 定向移動位置更新器
前文說到,我們將以60FPS的重新整理率更新介面,這也就意味著,每隔大約16ms重新整理一次小球位置。因為只有小球的運動,才能讓人感到介面在“更新”。這一步驟,我們用到Timer類。並將更新器在initState()方法中呼叫,以便程式啟動後,小球即刻運動,也就是前文程式碼中見到的startMove()方法。
```c
// 開始移動
void startMove() {
Timer.periodic(Duration(milliseconds: 16), (timer) {
moveBall();
setState(() {});
});
}
// 小球移動
void moveBall() {
_x += _step_x;
_y += _step_y;
}
```
到此為止,小球已經可以開始沿著某個隨機方向移動了。但很快,它將移出螢幕。
#### 邊界判定
顯然,小球每前進一步,都要做螢幕邊界判定,以防小球移出螢幕範圍。而邊界判定在moveBall()方法中實現似乎是最恰當的。
我們可以輕鬆地總結出小球移動的規律,當小球移動到螢幕邊緣時,我們只需讓其反向運動即可。比如,小球以3的速度移動並接觸螢幕的右邊緣,接下來,仍以3的速度移動並朝向螢幕的左邊緣。
水平方向如此,垂直方向亦如此。
因此,我們的邊界判定邏輯如下:
```c
// 帶有便捷判定的小球移動
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()方法:
```c
// 帶有便捷判定的小球移動
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程式碼:
```c
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 {
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();
}
}
}
}
```
讓我們一起讓這個程式跑起來吧!
![實現效果](https://img2020.cnblogs.com/blog/1595922/202007/1595922-20200730152455349-12260657