用Flutter作桌上彈球?聊聊繪圖(Canvas&CustomPaint)API

本文是Flutter中Canvas和CustomPaint API的使用實例。
首先看一下咱們要實現的效果:
實現效果
結合動圖演示,列出最終目標以下:git

  1. 在程序運行後,顯示一個小球;
  2. 每次程序啓動後,小球的樣式均發生隨機性變化,體如今大小、顏色和位置三點;
  3. 小球運行的規律參考桌球或三維彈球遊戲;
  4. 單擊屏幕,小球變色;
  5. 雙擊屏幕,小球暫停/恢復運動;
  6. 長按屏幕,小球開始/中止自動變色。

運用的主要技術點:
Canvas和CustomPaint API。github

運行平臺:
Android、iOScanvas

源碼地址:
Github
Giteeless


功能拆解

首先拆解前文中所列出的6個實現目標,顯而易見,要實現它們,咱們須要:dom

  1. 隨機顏色生成器;
  2. 隨機位置生成器;
  3. 隨機尺寸生成器;
  4. 小球繪製邏輯;
  5. 小球運動邏輯:
    • 邊界斷定;
    • 初始運動方向生成器;
    • 定向移動位置更新器。
  6. 用戶手勢監聽器。

功能實現

接下來,咱們逐步實現功能拆解中所列舉的6個具體功能。async

隨機顏色生成器

隨機顏色生成器在程序啓動、單擊屏幕和自動變色中使用。
在Flutter中,咱們能夠經過Color類對紅、綠、藍和透明度分別定義,來定義某個惟一的顏色,數值範圍是0-255。對於透明度,0表示徹底透明,255表示徹底不透明。
對於隨機數值,咱們使用Random類生成0-255之間的隨機整數。
隨機顏色生成器則主要使用上述兩個類來實現,具體代碼片斷以下:ide

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。
另外,咱們還須要分別獲取屏幕的寬高。
所以,具體代碼實現以下:函數

[獲取屏幕寬高]佈局

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

[生成隨機位置]ui

double _x = 0, _y = 0;

// 生成小球初始位置和大小
void generateBall() {
	_x = Random().nextDouble() * screenX;
	_y = Random().nextDouble() * screenY;
}

隨機尺寸生成器

隨機尺寸生成器在程序啓動時使用。
完成了以前兩種隨機值的生成,到了尺寸這裏,就很輕車熟路了。因爲隨機尺寸和隨機位置都在程序啓動時調用,且操做對象都是小球,咱們將其實現都放在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類除了構造方法外,只有兩個override的方法,能夠說是很簡單了。
在構造方法中,咱們初始化了_paint對象,它是能夠看作是「畫筆」;
在paint()方法中,咱們調用canvas對象的drawOval方法畫圓,表示小球。canvas能夠看作是「畫板」;
shouldRepaint()方法表示在刷新佈局的時是否須要重繪,只有在返回true時會發生重繪,這裏咱們讓程序自行判斷就能夠了。
咱們將上述代碼保存爲ball.dart備用。
注意,這裏面不管是位置、顏色還有尺寸,都沒有寫固定的值。是由於該類只負責「畫圓」,而具體畫什麼樣的圓,則交給該類的使用者來定義,也就是main.dart。
在main.dart中,咱們將App設置爲全屏,並添加全屏尺寸的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都是布爾類型變量,是小球移動和自動變色功能的開關。
接下來,咱們在initState()方法中調用以前的隨機位置生成器、隨機尺寸生成器和隨機顏色生成器,賦值_x、_y、_size和_color。

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

這裏面,calculateMoveAngle()和startMove()方法分別對應初始運動方向生成器以及開始運動並按期更新UI的方法。除了這兩個方法外,若是如今運行程序的話,應該能夠看到一個靜態的小球出如今屏幕上了,而且隨着每次從新運行程序,小球的樣式和位置都將發生變化。
接下來,咱們就來讓小球動起來吧!

小球運動邏輯

要讓小球準確無誤地運動,咱們須要遵循如下步驟:首先生成一個隨機的運動方向;而後以60FPS的頻率,每次在運動方向上前進5個像素的步長(固然,你能夠自定義);最後還要注意邊界斷定,在小球到達屏幕邊緣時正確轉向。
下面咱們逐個實現。

初始運動方向生成器

既然是隨機方向,那麼平面上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;
}

咱們這裏把運動速度(_speed)看作是三角形的斜邊,橫、縱座標的移動速度(_step_x、_step_y)看作是三角形的直角邊便可。沒記錯的話,都是初中幾何知識,不會很難理解。

定向移動位置更新器

前文說到,咱們將以60FPS的刷新率更新界面,這也就意味着,每隔大約16ms刷新一次小球位置。由於只有小球的運動,才能讓人感到界面在「更新」。這一步驟,咱們用到Timer類。並將更新器在initState()方法中調用,以便程序啓動後,小球即刻運動,也就是前文代碼中見到的startMove()方法。

// 開始移動
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();
      }
    }
  }
}

讓咱們一塊兒讓這個程序跑起來吧!
實現效果

相關文章
相關標籤/搜索