用Flutter做桌上彈球(繪圖(Canvas&CustomPaint)API)
本文是Flutter中Canvas和CustomPaint API的使用實(shí)例。
首先看一下我們要實(shí)現(xiàn)的效果:

結(jié)合動圖演示,列出最終目標(biāo)如下:
- 在程序運(yùn)行后,顯示一個(gè)小球;
- 每次程序啟動后,小球的樣式均發(fā)生隨機(jī)性變化,體現(xiàn)在大小、顏色和位置三點(diǎn);
- 小球運(yùn)行的規(guī)律參考桌球或三維彈球游戲;
- 單擊屏幕,小球變色;
- 雙擊屏幕,小球暫停/恢復(fù)運(yùn)動;
- 長按屏幕,小球開始/停止自動變色。
運(yùn)用的主要技術(shù)點(diǎn):Canvas和CustomPaint API。
運(yùn)行平臺:Android、iOS
功能拆解
首先拆解前文中所列出的6個(gè)實(shí)現(xiàn)目標(biāo),顯而易見,要實(shí)現(xiàn)它們,我們需要:
- 隨機(jī)顏色生成器;
- 隨機(jī)位置生成器;
- 隨機(jī)尺寸生成器;
- 小球繪制邏輯;
- 小球運(yùn)動邏輯:
邊界判定;
初始運(yùn)動方向生成器;
定向移動位置更新器。
- 用戶手勢監(jiān)聽器。
功能實(shí)現(xiàn)
接下來,我們逐步實(shí)現(xiàn)功能拆解中所列舉的6個(gè)具體功能。
隨機(jī)顏色生成器
隨機(jī)顏色生成器在程序啟動、單擊屏幕和自動變色中使用。在Flutter中,我們可以通過Color類對紅、綠、藍(lán)和透明度分別定義,來定義某個(gè)唯一的顏色,數(shù)值范圍是0-255。對于透明度,0表示完全透明,255表示完全不透明。
對于隨機(jī)數(shù)值,我們使用Random類生成0-255之間的隨機(jī)整數(shù)。
隨機(jī)顏色生成器則主要使用上述兩個(gè)類來實(shí)現(xiàn),具體代碼片段如下:
Color _color = Color.fromARGB(0, 0, 0, 0);
// 改變小球顏色
void changeColor() {
_color = Color.fromARGB(255, Random().nextInt(255), Random().nextInt(255),Random().nextInt(255));
}
隨機(jī)位置生成器
隨機(jī)位置生成器在程序啟動時(shí)使用。要生成隨機(jī)位置,方法依然是使用Random類,但要注意隨機(jī)值范圍。通常我們需要小球出現(xiàn)的位置在屏幕內(nèi),因此,我們需要生成兩次隨機(jī)數(shù),分別表示小球初始位置的x和y軸坐標(biāo)。坐標(biāo)值分別小于屏幕橫向尺寸和縱向尺寸。當(dāng)然,它們都要大于0。
另外,我們還需要分別獲取屏幕的寬高。
因此,具體代碼實(shí)現(xiàn)如下:
[獲取屏幕寬高]
double screenX, screenY;
@override
Widget build(BuildContext context) {
screenX = MediaQuery.of(context).size.width;
screenY = MediaQuery.of(context).size.height;
...
}
[生成隨機(jī)位置]
double _x = 0, _y = 0;
// 生成小球初始位置和大小
void generateBall() {
_x = Random().nextDouble() * screenX;
_y = Random().nextDouble() * screenY;
}
隨機(jī)尺寸生成器
隨機(jī)尺寸生成器在程序啟動時(shí)使用。完成了之前兩種隨機(jī)值的生成,到了尺寸這里,就很輕車熟路了。由于隨機(jī)尺寸和隨機(jī)位置都在程序啟動時(shí)調(diào)用,且操作對象都是小球,我們將其實(shí)現(xiàn)都放在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組件需要一個(gè)CustomPainter實(shí)例。小球的繪制工作主要在繼承了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;
}
}
通過閱讀上面的代碼,可以發(fā)現(xiàn),整個(gè)Ball類除了構(gòu)造方法外,只有兩個(gè)override的方法,可以說是很簡單了。
在構(gòu)造方法中,我們初始化了_paint對象,它是可以看做是“畫筆”;
在paint()方法中,我們調(diào)用canvas對象的drawOval方法畫圓,表示小球。canvas可以看做是“畫板”;
shouldRepaint()方法表示在刷新布局的時(shí)是否需要重繪,只有在返回true時(shí)會發(fā)生重繪,這里我們讓程序自行判斷就可以了。
我們將上述代碼保存為ball.dart備用。
注意,這里面無論是位置、顏色還有尺寸,都沒有寫固定的值。是因?yàn)樵擃愔回?fù)責(zé)“畫圓”,而具體畫什么樣的圓,則交給該類的使用者來定義,也就是main.dart。
在main.dart中,我們將App設(shè)置為全屏,并添加全屏尺寸的CustomPaint組件,組件內(nèi)放置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: () {
// 暫停/恢復(fù)移動
_keep_move = !_keep_move;
},
onLongPress: () {
// 自動改變小球顏色
_auto_change_color = !_auto_change_color;
},
));
}
上述代碼中,GestureDetector組件負(fù)責(zé)接收用戶點(diǎn)擊事件,其中的_keep_move、_auto_change_color都是布爾類型變量,是小球移動和自動變色功能的開關(guān)。
接下來,我們在initState()方法中調(diào)用之前的隨機(jī)位置生成器、隨機(jī)尺寸生成器和隨機(jī)顏色生成器,賦值_x、_y、_size和_color。
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
generateBall();
changeColor();
calculateMoveAngle();
startMove();
});
}
這里面,calculateMoveAngle()和startMove()方法分別對應(yīng)初始運(yùn)動方向生成器以及開始運(yùn)動并定期更新UI的方法。除了這兩個(gè)方法外,如果現(xiàn)在運(yùn)行程序的話,應(yīng)該可以看到一個(gè)靜態(tài)的小球出現(xiàn)在屏幕上了,并且隨著每次重新運(yùn)行程序,小球的樣式和位置都將發(fā)生變化。
接下來,我們就來讓小球動起來吧!
小球運(yùn)動邏輯
要讓小球準(zhǔn)確無誤地運(yùn)動,我們需要遵循以下步驟:首先生成一個(gè)隨機(jī)的運(yùn)動方向;然后以60FPS的頻率,每次在運(yùn)動方向上前進(jìn)5個(gè)像素的步長(當(dāng)然,你可以自定義);最后還要注意邊界判定,在小球到達(dá)屏幕邊緣時(shí)正確轉(zhuǎn)向。
下面我們逐個(gè)實(shí)現(xiàn)。
初始運(yùn)動方向生成器
既然是隨機(jī)方向,那么平面上360度范圍內(nèi)任何一個(gè)角度都有可能。因此,我們這里需要先生成0-360范圍內(nèi)的值。然后根據(jù)三角函數(shù)和運(yùn)動方向的速度,計(jì)算出橫、縱坐標(biāo)的速度。其實(shí)很簡單,就是勾股定理。
double _step_x, _step_y, _angle;
// 計(jì)算小球初始移動角度(方向)
void calculateMoveAngle() {
_angle = Random().nextDouble() * 360;
_step_x = sin(_angle) * _speed;
_step_y = cos(_angle) * _speed;
}
我們這里把運(yùn)動速度(_speed)看做是三角形的斜邊,橫、縱坐標(biāo)的移動速度(_step_x、_step_y)看做是三角形的直角邊即可。沒記錯(cuò)的話,都是初中幾何知識,不會很難理解。
定向移動位置更新器
前文說到,我們將以60FPS的刷新率更新界面,這也就意味著,每隔大約16ms刷新一次小球位置。因?yàn)橹挥行∏虻倪\(yùn)動,才能讓人感到界面在“更新”。這一步驟,我們用到Timer類。并將更新器在initState()方法中調(diào)用,以便程序啟動后,小球即刻運(yùn)動,也就是前文代碼中見到的startMove()方法。
// 開始移動
void startMove() {
Timer.periodic(Duration(milliseconds: 16), (timer) {
moveBall();
setState(() {});
});
}
// 小球移動
void moveBall() {
_x += _step_x;
_y += _step_y;
}
到此為止,小球已經(jīng)可以開始沿著某個(gè)隨機(jī)方向移動了。但很快,它將移出屏幕。
邊界判定
顯然,小球每前進(jìn)一步,都要做屏幕邊界判定,以防小球移出屏幕范圍。而邊界判定在moveBall()方法中實(shí)現(xiàn)似乎是最恰當(dāng)?shù)摹?br />
我們可以輕松地總結(jié)出小球移動的規(guī)律,當(dāng)小球移動到屏幕邊緣時(shí),我們只需讓其反向運(yùn)動即可。比如,小球以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;
}
用戶手勢監(jiān)聽器
最后,配合用戶手勢及相關(guān)的布爾變量,在每次刷新小球位置時(shí)實(shí)現(xiàn)變色和暫停移動。
繼續(xù)修改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();
}
}
}
到此,程序全部實(shí)現(xiàn)完成。下面放上完整的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: () {
// 暫停/恢復(fù)移動
_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;
}
// 計(jì)算小球初始移動角度(方向)
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();
}
}
}
}
讓我們一起讓這個(gè)程序跑起來吧!

到此這篇關(guān)于用Flutter做桌上彈球 聊聊繪圖(Canvas&CustomPaint)API的文章就介紹到這了,更多相關(guān)Flutter桌上彈球內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Android重寫View實(shí)現(xiàn)全新的控件
這篇文章主要介紹了Android重寫View來實(shí)現(xiàn)全新的控件,最難的一種自定義控件形式,感興趣的小伙伴們可以參考一下2016-05-05
Flutter質(zhì)感設(shè)計(jì)之持久底部面板
這篇文章主要為大家詳細(xì)介紹了Flutter質(zhì)感設(shè)計(jì)之持久底部面板,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-08-08
Android Studio實(shí)現(xiàn)幀動畫
這篇文章主要為大家詳細(xì)介紹了Android Studio實(shí)現(xiàn)幀動畫,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-11-11
Android沉浸式狀態(tài)欄的實(shí)現(xiàn)代碼
這篇文章主要為大家詳細(xì)介紹了Android沉浸式狀態(tài)欄的實(shí)現(xiàn)代碼,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-09-09
Android實(shí)現(xiàn)把文件存放在SDCard的方法
這篇文章主要介紹了Android實(shí)現(xiàn)把文件存放在SDCard的方法,涉及Android針對SDCard的讀寫技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-09-09
關(guān)于Android Studio安裝完后activity_main.xml前幾行報(bào)錯(cuò)的解決建議
這篇文章主要介紹了關(guān)于Android Studio安裝完后activity_main.xml前幾行報(bào)錯(cuò)的解決建議,具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-03-03
Android利用Service開發(fā)簡單的音樂播放功能
這篇文章主要介紹了Android利用Service開發(fā)簡單的音樂播放功能,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)吧2023-04-04
Android實(shí)現(xiàn)環(huán)信修改頭像和昵稱
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)環(huán)信修改頭像和昵稱,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-02-02

