Flutter 绘制集录 | 随机对称图案

关于本文画作

看到GitHub头像,有感而发。默认头像是一个5*5的格子,随机填充色块形成的图形

1[1]. 可指定每行(列)的格子个数,且为奇数
2[2]. 图形成左右对称
3[3]. 半侧的图像点随机出现随机个

效果展示

5*5 5*5 9*9
9*9 11*11 11*11

一、画布的栅格与坐标

1. 基本思路

如下: 将我们的白板想象成一个栅格( 当然你可以在纸上打打草稿,没必要画出来 ),这样就很容易看出关系。这时白板就变成了一个 平面坐标系 ,我们可以用一个 二维坐标点 描述一个位置。再绘制出来这个矩形。

现在创建Position类用于描述坐标位置。

1class Position {
2  final int x;
3  final int y;
4
5  Position(this.x, this.y);
6
7  @override
8  String toString() {
9    return 'Position{x: $x, y: $y}';
10  }
11}

2. 从一个点开始

将一个 Position 对象和 栅格中的一个矩形区域 对应起来  

Rect.fromLTWH 可以根据左上角坐标和矩形宽高绘制矩形

Position(1, 1) Position(4, 3) Position(3, 2)

1class PortraitPainter extends CustomPainter {
2  Paint _paint;//画笔
3  final int blockCount = 5// 块数
4  final position = Position(11); //点位
5
6  PortraitPainter():
7      _paint = Paint()..color = Colors.blue;
8
9  @override
10  void paint(Canvas canvas, Size size) {
11    // 裁剪当前区域
12    canvas.clipRect(
13        Rect.fromPoints(Offset.zero, Offset(size.width, size.height)));
14
15    var perW = size.width / blockCount;
16    var perH = size.height / blockCount;
17    _drawBlock(perW, perH, canvas, position);
18  }
19
20  // 绘制块
21  void _drawBlock(double perW, double perH, Canvas canvas, Position position) {
22    canvas.drawRect(
23        Rect.fromLTWH(position.x * perW, position.y * perH, perW, perH), _paint);
24  }
25
26  @override
27  bool shouldRepaint(PortraitPainter oldDelegate) => true;
28}

3. 绘制多点

当你能绘制一个点时,这个问题就已经从 图像问题 转化为 坐标问题
使用坐标集 List ,通过 遍历坐标集, 绘制矩形块 即可

多点 去线

1final List positions = [
2  Position(10),
3  Position(21),
4  Position(01),
5  Position(02),
6  Position(13),
7  Position(24),
8  Position(30),
9  Position(21),
10  Position(41),
11  Position(42),
12  Position(33),
13];
14
15@override
16void paint(Canvas canvas, Size size) {
17  //英雄所见...
18  // 遍历坐标集, 绘制块
19  positions.forEach((element) {
20    _drawBlock(perW, perH, canvas, element);
21  });
22}

二、随机数和数据操作

上面已经完成了数据与图形的对应关系,达到了 数即形,形即数的数形合一 境界。 

一般在画板类中接收数据,画板中仅进行绘制的相关操作,可以提取出需要DIY的变量。

1. 画板类:PortraitPainter

1class PortraitPainter extends CustomPainter {
2  Paint _paint;
3
4  final int blockCount;
5  final Color color;
6  final List positions;
7
8  PortraitPainter(this.positions, {this.blockCount = 9,this.color=Colors.blue})
9      : _paint = Paint()..color = color;
10
11  @override
12  void paint(Canvas canvas, Size size) {
13    canvas.clipRect(
14        Rect.fromPoints(Offset.zero, Offset(size.width, size.height)));
15
16    var perW = size.width / blockCount;
17    var perH = size.height / blockCount;
18
19    positions.forEach((element) {
20      _drawBlock(perW, perH, canvas, element);
21    });
22  }
23
24  void _drawBlock(double dW, double dH, Canvas canvas, Position position) {
25    canvas.drawRect(
26        Rect.fromLTWH(position.x * dW, position.y * dH, dW, dH), _paint);
27  }
28
29  @override
30  bool shouldRepaint(PortraitPainter oldDelegate) => true;
31}

2.组件类:RandomPortrait

通过 CustomPaint 使用画板,这里为了方便演示,点击时会刷新重建图形  

现在只需要按照需求完成坐标点的生成即可。

1class RandomPortrait extends StatefulWidget {
2  @override
3  _RandomPortraitState createState() => _RandomPortraitState();
4}
5
6class _RandomPortraitState extends State<RandomPortrait{
7  List positions = [];
8  Random random = Random();
9  final int blockCount = 9;
10
11  @override
12  Widget build(BuildContext context) {
13    _initPosition();
14    return GestureDetector(
15        onTap: () {
16          setState(() {});
17        },
18        child: CustomPaint(
19            painter: PortraitPainter(positions, blockCount: blockCount)));
20  }
21
22  void _initPosition() {
23    // TODO 生成坐标点集
24  }
25}

3.生成点集

思路是先 生成左半边的点 ,然后遍历点,左侧非中间的点时,添加对称点。关于对称处理:

1如果a点和b点关于x=c对称。 
2则 (a.x + b.x)/2 = c
3即 b.x = 2*c - a.x

1 2 3

1  void _initPosition() {
2    positions.clear(); // 先清空点集
3
4    // 左半边的数量 (随机)
5    int randomCount = 2 + random.nextInt(blockCount * blockCount ~/ 2 - 2);
6    // 对称轴
7    var axis = blockCount ~/ 2 ;
8    //添加左侧随机点
9    for (int i = 0; i < randomCount; i++) {
10      int randomX = random.nextInt(axis+ 1);
11      int randomY = random.nextInt(blockCount);
12      var position = Position(randomX, randomY);
13      positions.add(position);
14    }
15    //添加对称点
16    for (int i = 0; i < positions.length; i++) {
17      if (positions[i].x < blockCount ~/ 2) {
18        positions
19            .add(Position(2 * axis - positions[i].x, positions[i].y));
20      }
21    }
22  }

这样基本上就完成了,后面可以做些优化

4. 小优化

[1]. 可以在绘制时留些边距,这样好看些

[2]. 当格数为9*9时,由于除不尽,可能导致相连块的小间隙(下图2),可以通过边长取整来解决

留边距 小间隙 小间隙优化

1class PortraitPainter extends CustomPainter {
2  Paint _paint;
3
4  final int blockCount;
5  final Color color;
6  final List positions;
7
8  final pd = 20.0;
9
10  PortraitPainter(this.positions,
11      {this.blockCount = 9this.color = Colors.blue})
12      : _paint = Paint()..color = color;
13
14  @override
15  void paint(Canvas canvas, Size size) {
16    canvas.clipRect(
17        Rect.fromPoints(Offset.zero, Offset(size.width, size.height)));
18
19    var perW = (size.width - pd * 2) / (blockCount);
20    var perH = (size.height - pd * 2) / (blockCount);
21
22    canvas.translate(pd, pd);
23    positions.forEach((element) {
24      _drawBlock(perW, perH, canvas, element);
25    });
26  }
27
28  void _drawBlock(double dW, double dH, Canvas canvas, Position position) {
29    canvas.drawRect(
30        Rect.fromLTWH(
31            position.x * dW.floor()*1.0
32            position.y * dH.floor()*1.0
33            dW.floor()*1.0
34            dH.floor()*1.0), _paint);
35  }
36
37  @override
38  bool shouldRepaint(PortraitPainter oldDelegate) => true;
39}

三、canvas绘制保存为图片

可以通过很多方法来读取一个Widget对应的图片数据,这里我使用 RepaintBoundary ,并简单封装了一下。获取图片数据后,可以根据需求保存到本地成为图片,也可以发送到服务器中,作为用户头像。反正字节流在手,万事无忧。

1.Widget2Image组件

简单封装一下,简化Widget2Image的操作流程。

1class Widget2Image extends StatefulWidget {
2  final Widget child;
3  final ui.ImageByteFormat format;
4
5  Widget2Image(
6      {@required this.child,
7        this.format = ui.ImageByteFormat.rawRgba});
8
9  @override
10  Widget2ImageState createState() => Widget2ImageState();
11
12
13  static Widget2ImageState of(BuildContext context) {
14    final Widget2ImageState result = context.findAncestorStateOfType();
15    if (result != null)
16      return result;
17    throw FlutterError.fromParts([
18      ErrorSummary(
19          'Widget2Image.of() called with a context that does not contain a Widget2Image.'
20      ),
21    ]);
22  }
23}
24
25class Widget2ImageState extends State<Widget2Image{
26  final GlobalKey _globalKey = GlobalKey();
27
28  @override
29  Widget build(BuildContext context) {
30    return RepaintBoundary(
31      key: _globalKey,
32      child: widget.child,
33    );
34  }
35
36  Future loadImage() {
37    return _widget2Image(_globalKey);
38  }
39
40  Future _widget2Image(GlobalKey key) async {
41    RenderRepaintBoundary boundary = key.currentContext.findRenderObject();
42    //获得 ui.image
43    ui.Image img = await boundary.toImage();
44    //获取图片字节
45    var byteData = await img.toByteData(format: widget.format);
46    Uint8List bits = byteData.buffer.asUint8List();
47    return bits;
48  }
49}

2. 使用 Widget2Image

1  @override
2  Widget build(BuildContext context) {
3    _initPosition();
4    return Widget2Image( // 使用 
5        format: ImageByteFormat.png,
6        child: Builder( // 使用Builder,让上下文下沉一级
7          builder: (ctx) => GestureDetector(
8            onTap: () {
9              setState(() {});
10            },
11            onLongPress: () async { // 长按时执行获取图片方法
12              var bytes = await Widget2Image.of(ctx).loadImage();
13
14              // 获取到图片字节数据 ---- 之后可随意操作
15              final dir = await getTemporaryDirectory();
16              final dest = path.join(dir.path, "widget.png");
17              await File(dest).writeAsBytes(bytes);
18              Scaffold.of(context)
19                  .showSnackBar(SnackBar(content: Text("图片已保存到:$dest")));
20            },
21            child: CustomPaint(
22                painter: PortraitPainter(positions, blockCount: blockCount)),
23          ),
24        ));
25  }

本文到这来就接近尾声了,应该是蛮有意思的。其实根据坐标系,可以做出很多有意思的东西。比如并非一定是画矩形,也可以画圆、三角形、甚至是图片。

如果把栅格分的更细些,这就很像一个 像素世界 。基于此,做个俄罗斯方块或者贪吃蛇什么的应该也可以。 

最想说的一点是: 驱动视图显示的是背后的数据, 脑洞会让数据拥有无限可能