阅读 1802

【-Flutter绘制集录-】第一画: 随机对称点头像

零、前言

1. 关于FlutterUnit 绘制集录

本文隶属于FlutterUnit周边,项目地址: FlutterUnit

FlutterUnit绘制集录已拉开序幕,此集录会收录一些有意思的绘制作品,或一些典型的绘制样例来让大家接触Flutter更广大的可能性。(下面的黑框也是绘制出来的哦)

The ChaosRandom PortraitTriangular MeshHypnotic Squares

2.关于本文画作 相关源码见这里

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

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

效果展示

5*55*59*9
9*911*1111*11

3.这有什么用?
[1]. 练习绘制能力
[2]. 练习操纵数据的能力
[3]. 将widget保存为图片,你能获得默认头像
[4]. 最重要的是,挺好玩的~
复制代码

一、画布的栅格与坐标

1. 基本思路

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

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

class Position {
  final int x;
  final int y;

  Position(this.x, this.y);

  @override
  String toString() {
    return 'Position{x: $x, y: $y}';
  }
}
复制代码

2. 从一个点开始

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

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

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

class PortraitPainter extends CustomPainter {
  Paint _paint;//画笔
  final int blockCount = 5; // 块数
  final position = Position(1, 1); //点位

  PortraitPainter():
      _paint = Paint()..color = Colors.blue;

  @override
  void paint(Canvas canvas, Size size) {
    // 裁剪当前区域
    canvas.clipRect(
        Rect.fromPoints(Offset.zero, Offset(size.width, size.height)));
        
    var perW = size.width / blockCount;
    var perH = size.height / blockCount;
    _drawBlock(perW, perH, canvas, position);
  }

  // 绘制块
  void _drawBlock(double perW, double perH, Canvas canvas, Position position) {
    canvas.drawRect(
        Rect.fromLTWH(position.x * perW, position.y * perH, perW, perH), _paint);
  }

  @override
  bool shouldRepaint(PortraitPainter oldDelegate) => true;
}
复制代码

3. 绘制多点

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

多点去线
final List<Position> positions = [
  Position(1, 0),
  Position(2, 1),
  Position(0, 1),
  Position(0, 2),
  Position(1, 3),
  Position(2, 4),
  Position(3, 0),
  Position(2, 1),
  Position(4, 1),
  Position(4, 2),
  Position(3, 3),
];

@override
void paint(Canvas canvas, Size size) {
  //英雄所见...
  // 遍历坐标集, 绘制块
  positions.forEach((element) {
    _drawBlock(perW, perH, canvas, element);
  });
}
复制代码

二、随机数和数据操作

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

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


1. 画板类:PortraitPainter
class PortraitPainter extends CustomPainter {
  Paint _paint;

  final int blockCount;
  final Color color;
  final List<Position> positions;

  PortraitPainter(this.positions, {this.blockCount = 9,this.color=Colors.blue})
      : _paint = Paint()..color = color;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(
        Rect.fromPoints(Offset.zero, Offset(size.width, size.height)));

    var perW = size.width / blockCount;
    var perH = size.height / blockCount;
    
    positions.forEach((element) {
      _drawBlock(perW, perH, canvas, element);
    });
  }
  
  void _drawBlock(double dW, double dH, Canvas canvas, Position position) {
    canvas.drawRect(
        Rect.fromLTWH(position.x * dW, position.y * dH, dW, dH), _paint);
  }

  @override
  bool shouldRepaint(PortraitPainter oldDelegate) => true;
}
复制代码

2.组件类:RandomPortrait

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

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

class RandomPortrait extends StatefulWidget {
  @override
  _RandomPortraitState createState() => _RandomPortraitState();
}

class _RandomPortraitState extends State<RandomPortrait> {
  List<Position> positions = [];
  Random random = Random();
  final int blockCount = 9;

  @override
  Widget build(BuildContext context) {
    _initPosition();
    return GestureDetector(
        onTap: () {
          setState(() {});
        },
        child: CustomPaint(
            painter: PortraitPainter(positions, blockCount: blockCount)));
  }

  void _initPosition() {
    // TODO 生成坐标点集
  }
}
复制代码

3.生成点集

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

如果a点和b点关于x=c对称。 
则 (a.x + b.x)/2 = c
即 b.x = 2*c - a.x
复制代码
123
  void _initPosition() {
    positions.clear(); // 先清空点集
    
    // 左半边的数量 (随机)
    int randomCount = 2 + random.nextInt(blockCount * blockCount ~/ 2 - 2);
    // 对称轴
    var axis = blockCount ~/ 2 ;
    //添加左侧随机点
    for (int i = 0; i < randomCount; i++) {
      int randomX = random.nextInt(axis+ 1);
      int randomY = random.nextInt(blockCount);
      var position = Position(randomX, randomY);
      positions.add(position);
    }
    //添加对称点
    for (int i = 0; i < positions.length; i++) {
      if (positions[i].x < blockCount ~/ 2) {
        positions
            .add(Position(2 * axis - positions[i].x, positions[i].y));
      }
    }
  }
复制代码

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


4. 小优化

[1]. 可以在绘制时留些边距,这样好看些
[2]. 当格数为9*9时,由于除不尽,可能导致相连块的小间隙(下图2),可以通过边长取整来解决

留边距小间隙小间隙优化
class PortraitPainter extends CustomPainter {
  Paint _paint;

  final int blockCount;
  final Color color;
  final List<Position> positions;

  final pd = 20.0;

  PortraitPainter(this.positions,
      {this.blockCount = 9, this.color = Colors.blue})
      : _paint = Paint()..color = color;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(
        Rect.fromPoints(Offset.zero, Offset(size.width, size.height)));
    
    var perW = (size.width - pd * 2) / (blockCount);
    var perH = (size.height - pd * 2) / (blockCount);

    canvas.translate(pd, pd);
    positions.forEach((element) {
      _drawBlock(perW, perH, canvas, element);
    });
  }

  void _drawBlock(double dW, double dH, Canvas canvas, Position position) {
    canvas.drawRect(
        Rect.fromLTWH(
            position.x * dW.floor()*1.0, 
            position.y * dH.floor()*1.0, 
            dW.floor()*1.0, 
            dH.floor()*1.0), _paint);
  }

  @override
  bool shouldRepaint(PortraitPainter oldDelegate) => true;
}
复制代码

三、canvas绘制保存为图片

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


1.Widget2Image组件

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

class Widget2Image extends StatefulWidget {
  final Widget child;
  final ui.ImageByteFormat format;

  Widget2Image(
      {@required this.child,
        this.format = ui.ImageByteFormat.rawRgba});

  @override
  Widget2ImageState createState() => Widget2ImageState();


  static Widget2ImageState of(BuildContext context) {
    final Widget2ImageState result = context.findAncestorStateOfType<Widget2ImageState>();
    if (result != null)
      return result;
    throw FlutterError.fromParts(<DiagnosticsNode>[
      ErrorSummary(
          'Widget2Image.of() called with a context that does not contain a Widget2Image.'
      ),
    ]);
  }
}

class Widget2ImageState extends State<Widget2Image> {
  final GlobalKey _globalKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return RepaintBoundary(
      key: _globalKey,
      child: widget.child,
    );
  }

  Future<Uint8List> loadImage() {
    return _widget2Image(_globalKey);
  }

  Future<Uint8List> _widget2Image(GlobalKey key) async {
    RenderRepaintBoundary boundary = key.currentContext.findRenderObject();
    //获得 ui.image
    ui.Image img = await boundary.toImage();
    //获取图片字节
    var byteData = await img.toByteData(format: widget.format);
    Uint8List bits = byteData.buffer.asUint8List();
    return bits;
  }
}
复制代码

2. 使用 Widget2Image
  @override
  Widget build(BuildContext context) {
    _initPosition();
    return Widget2Image( // 使用 
        format: ImageByteFormat.png,
        child: Builder( // 使用Builder,让上下文下沉一级
          builder: (ctx) => GestureDetector(
            onTap: () {
              setState(() {});
            },
            onLongPress: () async { // 长按时执行获取图片方法
              var bytes = await Widget2Image.of(ctx).loadImage();
           
              // 获取到图片字节数据 ---- 之后可随意操作
              final dir = await getTemporaryDirectory();
              final dest = path.join(dir.path, "widget.png");
              await File(dest).writeAsBytes(bytes);
              Scaffold.of(context)
                  .showSnackBar(SnackBar(content: Text("图片已保存到:$dest")));
            },
            child: CustomPaint(
                painter: PortraitPainter(positions, blockCount: blockCount)),
          ),
        ));
  }
复制代码

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

如果把栅格分的更细些,这就很像一个像素世界。基于此,做个俄罗斯方块或者贪吃蛇什么的应该也可以。
最想说的一点是: 驱动视图显示的是背后的数据, 脑洞会让数据拥有无限可能

最后欢迎大家多多支持 FlutterUnit

@张风捷特烈 2020.10.11 未允禁转
~ END ~