定個小目標,實現(xiàn)一個簡單的圍棋人機對弈,因為公司就是做的圍棋相關(guān),自定義棋盤必然是UI上繞不過去的一個坎。所以還是花了2天的時間來了解flutter的自定義控件,當(dāng)然我了解的比較粗略,主要還是嘗試下flutter能不能滿足公司項目的要求,而且之前提過了,這并不是什么嚴謹?shù)募夹g(shù)性文章,只是自己學(xué)習(xí)和采坑過程中的一個記錄。
先看一下最終的效果吧:

整體構(gòu)思
先說一下動手之前的一些想法吧,因為我本行是android,所以很多想法會更貼合android一些,關(guān)于自定義控件,一般有三種方式:
- 組合原生控件
- 繼承某個原生控件,做一些簡單修改
- 繼承基類,從繪圖開始,完全自定義一個控件
而flutter基本也是這個套路,很明顯,做一個棋盤的話我們只能選擇第三種方式,它最復(fù)雜,功能也最強大。
Flutter中我們常用的Widget有StatelessWidget和StatefulWidget兩種,對他們的簡單理解為StatelessWidget為無狀態(tài)widget,StatefulWidget為有狀態(tài)widget,不過要注意的是:
在Flutter中Widget是不可變的,不會直接更新,這一點StatelessWidget和StatefulWidget都是一樣的,他們每一幀都會重新build,不同的是StatefulWidget維護了一個State對象,它可以跨幀存儲狀態(tài)數(shù)據(jù)并恢復(fù)它。
很明顯,我們的棋盤是一個有狀態(tài)的widget,所以應(yīng)該繼承自StatefulWidget。但是考慮到實際使用過程中棋盤的變化大部分情況下其實只是棋子在變化,背景并不需要變化,所以考慮將棋盤分為背景和棋子兩層,最后再將他們組合起來,防止每次落子時背景都要重繪。
實踐
棋盤
棋盤為正方形,考慮使用AspectRatio將寬高比例設(shè)置為1:1 。子控件用Stack,將背景widget和棋子widget層疊在一起。
class TileView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1 / 1, //設(shè)置寬高比例
child: Stack(
alignment: AlignmentDirectional.topStart,
textDirection: TextDirection.ltr,
fit: StackFit.expand,
children: <Widget>[
LayerBackground(),
LayerChess()
],
),
);
}
}
背景層
整體框架
為了方便自定義控件,F(xiàn)lutter提供了CustomPaint和CustomPainter兩個類來方便我們將自己的算法繪制到畫布。其中CustomPaint是一個widget,需要傳入一個CustomPainter來進行實例化,在CustomPainter的paint方法中我們可以進行相關(guān)的繪制操作。
繪制棋盤背景我們需要根據(jù)棋盤的路數(shù)來計算線條的坐標,所以棋盤路數(shù)是我們必須的一個條件,到這里應(yīng)該可以得到如下代碼:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:tile/Coordinate.dart';
import 'package:tile/Utils.dart';
class LayerBackground extends StatefulWidget {
///棋盤路數(shù)
int boardSize;
LayerBackground({
@required this.boardSize
});
@override
State<StatefulWidget> createState() {
return LayerBackgroundState();
}
}
class LayerBackgroundState extends State<LayerBackground>{
@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 相關(guān)繪制操作
}
@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;
}
}
設(shè)置畫筆
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()表示通過傳入左上角坐標和寬高尺寸來確定的一個矩形區(qū)域。
繪制網(wǎng)格線
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來繪制。并且在繪制之前必須執(zhí)行l(wèi)ayout操作,用來計算文字的尺寸和位置。注意TextPainter的text、textDirection兩個參數(shù)必須傳入,否則會報錯。
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是個工具類,生成一個星位的列表,然后遍歷點位去畫圓。
此時背景層就基本完成了,效果圖如下:

棋子層
棋子層的實現(xiàn)邏輯和背景層是一樣的,區(qū)別只在于具體的繪制方法,棋子層只需要繪制棋子和手順,即畫圓和文字,難點在于圍棋的邏輯,這點跟自定義控件就沒什么關(guān)系了,而且代碼較多,就不上了,如果只是想看看效果的,可以簡單的只是維護一個二維數(shù)組,或者github上搜一下開源的圍棋控件,應(yīng)該有java版的,翻譯成dart就可以了。
值得一提的是點擊方式的實現(xiàn),android中的view可以直接setOnClickListener添加點擊事件的監(jiān)聽,flutter中可以通過手勢(GestureDetector)來添加觸摸相關(guān)事件,也有個別的widget(比如RaisedButton)提供了添加點擊事件的方法,其實底層也是用手勢來實現(xiàn)的。
@override
Widget build(BuildContext context) {
print('layer chess build');
return GestureDetector(
onTapUp: _onTapUp,//點擊方法
child: CustomPaint(
painter: _LayerChessPainter(board,widget.boardSize,widget.tileNum)
));
}
當(dāng)然,GestureDetector除了單點事件,還有很多其他的事件,比如長按、縮放、雙擊、拖拽等等:
