做个桌上弹球?Flutter中的绘图(Canvas&CustomPaint)API

本文是Flutter中Canvas和CustomPaint API的使用实例。
首先看一下我们要实现的效果:

实现效果

结合动图演示,列出最终目标如下:

  1. 在程序运行后,显示一个小球;
  2. 每次程序启动后,小球的样式均发生随机性变化,体现在大小、颜色和位置三点;
  3. 小球运行的规律参考桌球或三维弹球游戏;
  4. 单击屏幕,小球变色;
  5. 双击屏幕,小球暂停/恢复运动;
  6. 长按屏幕,小球开始/停止自动变色。

运用的主要技术点:
Canvas和CustomPaint API。

运行平台:
Android、iOS

源码地址:
Github
Gitee


功能拆解

首先拆解前文中所列出的6个实现目标,显而易见,要实现它们,我们需要:

  1. 随机颜色生成器;
  2. 随机位置生成器;
  3. 随机尺寸生成器;
  4. 小球绘制逻辑;
  5. 小球运动逻辑:
    • 边界判定;
    • 初始运动方向生成器;
    • 定向移动位置更新器。
  6. 用户手势监听器。

功能实现

接下来,我们逐步实现功能拆解中所列举的6个具体功能。

随机颜色生成器

随机颜色生成器在程序启动、单击屏幕和自动变色中使用。
在Flutter中,我们可以通过Color类对红、绿、蓝和透明度分别定义,来定义某个唯一的颜色,数值范围是0-255。对于透明度,0表示完全透明,255表示完全不透明。
对于随机数值,我们使用Random类生成0-255之间的随机整数。
随机颜色生成器则主要使用上述两个类来实现,具体代码片段如下:

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;
    ...
}

[生成随机位置]

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();
      }
    }
  }
}

让我们一起让这个程序跑起来吧!


实现效果
?著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,172评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,346评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事?!?“怎么了?”我有些...
    开封第一讲书人阅读 159,788评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,299评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,409评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,467评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,476评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,262评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,699评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,994评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,167评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,827评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,499评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,149评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,387评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,028评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,055评论 2 352