定个小目标,实现一个简单的围棋人机对弈,因为公司就是做的围棋相关,自定义棋盘必然是UI上绕不过去的一个坎。所以还是花了2天的时间来了解flutter的自定义控件,当然我了解的比较粗略,主要还是尝试下flutter能不能满足公司项目的要求,而且之前提过了,这并不是什么严谨的技术性文章,只是自己学习和采坑过程中的一个记录。
先看一下最终的效果吧:
整体构思
先说一下动手之前的一些想法吧,因为我本行是android,所以很多想法会更贴合android一些,关于自定义控件,一般有三种方式:
- 组合原生控件
- 继承某个原生控件,做一些简单修改
- 继承基类,从绘图开始,完全自定义一个控件
而flutter基本也是这个套路,很明显,做一个棋盘的话我们只能选择第三种方式,它最复杂,功能也最强大。
Flutter中我们常用的Widget有StatelessWidget和StatefulWidget两种,对他们的简单理解为StatelessWidget为无状态widget,StatefulWidget为有状态widget,不过要注意的是:
在Flutter中Widget是不可变的,不会直接更新,这一点StatelessWidget和StatefulWidget都是一样的,他们每一帧都会重新build,不同的是StatefulWidget维护了一个State对象,它可以跨帧存储状态数据并恢复它。
很明显,我们的棋盘是一个有状态的widget,所以应该继承自StatefulWidget。但是考虑到实际使用过程中棋盘的变化大部分情况下其实只是棋子在变化,背景并不需要变化,所以考虑将棋盘分为背景和棋子两层,最后再将他们组合起来,防止每次落子时背景都要重绘。
实践
棋盘
棋盘为正方形,考虑使用AspectRatio将宽高比例设置为1:1 。子控件用Stack,将背景widget和棋子widget层叠在一起。
class TileView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1 / 1, //设置宽高比例
child: Stack(
alignment: AlignmentDirectional.topStart,
textDirection: TextDirection.ltr,
fit: StackFit.expand,
children: [
LayerBackground(),
LayerChess()
],
),
);
}
}
背景层
整体框架
为了方便自定义控件,Flutter提供了CustomPaint和CustomPainter两个类来方便我们将自己的算法绘制到画布。其中CustomPaint是一个widget,需要传入一个CustomPainter来进行实例化,在CustomPainter的paint方法中我们可以进行相关的绘制操作。
绘制棋盘背景我们需要根据棋盘的路数来计算线条的坐标,所以棋盘路数是我们必须的一个条件,到这里应该可以得到如下代码:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tile/Coordinate.dart';
import 'package:tile/Utils.dart';
class LayerBackground extends StatefulWidget {
///棋盘路数
int boardSize;
LayerBackground({
@required this.boardSize
});
@override
State createState() {
return LayerBackgroundState();
}
}
class LayerBackgroundState extends State{
@override
Widget build(BuildContext context) {
print('layer background build');
return CustomPaint(
painter: _LayerBackgroundUI(widget.boardSize),
);
}
}
class _LayerBackgroundUI extends CustomPainter {
int boardSize;
_LayerBackgroundUI(this.boardSize);
@override
void paint(Canvas canvas, Size size) {
//todo 相关绘制操作
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}
尺寸计算
class _LayerBackgroundUI extends CustomPainter {
///棋盘尺寸
double _width;
///每个格子的尺寸
double _tileSize;
///左右第一条边线和边界的距离
double _xOffset;
///上下第一条边线和边界的距离
double _yOffset;
int boardSize;
_LayerBackgroundUI(this.boardSize);
@override
void paint(Canvas canvas, Size size) {
print('background size:$size');
if (_width == null) {
_width = min(size.width, size.height);
}
if (_tileSize == null) {
_tileSize = _width / (boardSize + 1);
_xOffset = _tileSize * 1;
_yOffset = _tileSize * 1;
}
}
设置画笔
Paint _paintBg = Paint()
..color = Colors.yellow //画笔颜色
..strokeWidth = 15.0;
Paint _paintLine = Paint()..color = Colors.black;
绘制背景
void drawBackground(Canvas canvas, double width) {
//画矩形
canvas.drawRect(Rect.fromLTWH(0, 0, width, width), _paintBg);
}
Rect.fromLTWH()表示通过传入左上角坐标和宽高尺寸来确定的一个矩形区域。
绘制网格线
void drawLines(Canvas canvas, double width) {
for (int i = 1; i < boardSize + 1; i++) {
//画线
canvas.drawLine(Offset(x2Screen(1), y2Screen(i)),
Offset(x2Screen(boardSize), y2Screen(i)), _paintLine);
canvas.drawLine(Offset(x2Screen(i), y2Screen(1)),
Offset(x2Screen(i), y2Screen(boardSize)), _paintLine);
}
}
double x2Screen(int x) {
return (x - 1) * _tileSize + _xOffset;
}
double y2Screen(int y) {
return (boardSize - y) * _tileSize + _xOffset;
}
Offset表示一个点。drawLine通过传入两个点来确定一条线。
绘制标记
这里涉及到文字的绘制方法,flutter中的canvas并没有提供绘制文字的api,而是通过TextPainter来绘制。并且在绘制之前必须执行layout操作,用来计算文字的尺寸和位置。注意TextPainter的text、textDirection两个参数必须传入,否则会报错。
void drawCoordinate(Canvas canvas) {
for(int i = 1; i <= boardSize; i++){
TextSpan textSpan = TextSpan(
style: TextStyle(
color: Colors.black,
fontSize: _tileSize * 2/5
),
text: getAlpha(i-1)
);
TextPainter textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr
);
//x
textPainter.layout();
textPainter.paint(canvas, Offset(_tileSize * (i - 1) + _xOffset - textPainter.width/2, (_yOffset - textPainter.height) / 2));
textSpan = TextSpan(
style: TextStyle(
color: Colors.black,
fontSize: _tileSize * 2/5
),
text: (boardSize - i + 1).toString()
);
textPainter = TextPainter(
text: textSpan,
textDirection: TextDirection.ltr
);
//y
textPainter.layout();
textPainter.paint(canvas, Offset((_xOffset - textPainter.width) / 2, _tileSize * (i - 1) + _yOffset- textPainter.height/2));
}
}
String getAlpha(int i) {
String list = "ABCDEFGHIJKLMNOPQRS";
return list[i];
}
绘制星位
void drawStars(Canvas canvas) {
double starSize = boardSize <= 9 ? _tileSize / 10 : _tileSize / 8;
for (Coordinate c in Utils.createStar(boardSize)) {
if(c!=null){
// 画圆
canvas.drawOval(Rect.fromCircle(center: Offset(x2Screen(c.x), y2Screen(c.y)),radius: starSize), _paintLine);
}
}
}
Utils是个工具类,生成一个星位的列表,然后遍历点位去画圆。
此时背景层就基本完成了,效果图如下:
棋子层
棋子层的实现逻辑和背景层是一样的,区别只在于具体的绘制方法,棋子层只需要绘制棋子和手顺,即画圆和文字,难点在于围棋的逻辑,这点跟自定义控件就没什么关系了,而且代码较多,就不上了,如果只是想看看效果的,可以简单的只是维护一个二维数组,或者github上搜一下开源的围棋控件,应该有java版的,翻译成dart就可以了。
值得一提的是点击方式的实现,android中的view可以直接setOnClickListener添加点击事件的监听,flutter中可以通过手势(GestureDetector)来添加触摸相关事件,也有个别的widget(比如RaisedButton)提供了添加点击事件的方法,其实底层也是用手势来实现的。
@override
Widget build(BuildContext context) {
print('layer chess build');
return GestureDetector(
onTapUp: _onTapUp,//点击方法
child: CustomPaint(
painter: _LayerChessPainter(board,widget.boardSize,widget.tileNum)
));
}
当然,GestureDetector除了单点事件,还有很多其他的事件,比如长按、缩放、双击、拖拽等等: