随着 flutter 的兴起,越来越多的公司开始使用 flutter ,最近一老同事问我关于如何使用 flutter 实现一个你画我猜的小游戏,现把这个分享给大家~
已实现的功能
- 画板自由涂鸦
- 选择画笔颜色
- 选择画笔大小
- 撤销到上一步
- 反撤销
- 清空画布
- 橡皮擦
- 基于 WebSocket 实时发送到服务器
- WebSocket 服务端转发给其它连接
- 接受 WebSocket 的消息内容绘制
使用到的技术
- 基础组件的使用(Scaffold、AppBar、IconButton、Container、Column、Stack、Padding、Icon 等)
- 自定义 CustomPainter ,在 Canvas 上使用 Paint 绘制
- 手势识别 GestureDetector 事件的使用
- Flutter 基于 Provider 插件的状态管理实现
- 简单的实现 WebSocket 通讯(真实项目考虑的问题要更多,比如心跳,重连,网络波动处理等)
最终效果
三屏实时同步
实战开始
- 打开 pubspec.yaml 引用状态管理和 WebSocket 库
dev_dependencies: flutter_test: sdk: flutter provider: ^4.0.1 web_socket_channel: ^1.1.0
- 创建 draw_entity.dart 实体类
import 'package:flutter/widgets.dart';//基础实体( pengzhenkun - 2020.04.30 )class DrawEntity { Offset offset; String color; double strokeWidth; DrawEntity(this.offset, {this.color = "default", this.strokeWidth = 5.0});}
- 创建 signature_painter.dart 自定义画板
import 'package:flutter/material.dart';import 'package:fluttercontrol/page/drawguess/draw_entity.dart';import 'package:fluttercontrol/page/drawguess/draw_provider.dart';//自定义 Canvas 画板( pengzhenkun - 2020.04.30 )class SignaturePainter extends CustomPainter { List pointsList; Paint pt; SignaturePainter(this.pointsList) { pt = Paint() //设置笔的属性 ..color = pintColor["default"] ..strokeCap = StrokeCap.round ..isAntiAlias = true ..strokeWidth = 3.0 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.bevel; } void paint(Canvas canvas, Size size) { for (int i = 0; i < pointsList.length - 1; i++) { //画线 if (pointsList[i] != null && pointsList[i + 1] != null) { pt ..color = pintColor[pointsList[i].color] ..strokeWidth = pointsList[i].strokeWidth; canvas.drawLine(pointsList[i].offset, pointsList[i + 1].offset, pt); } } }//是否重绘 bool shouldRepaint(SignaturePainter other) => other.pointsList != pointsList;}
- 创建 draw_provider.dart 状态管理
- 记录 撤销的数据、存储要画的数据、预处理的数据、默认颜色、默认字体大小、Socket连接(为了好理解,Socket连接也写在了此类)
import 'dart:convert';import 'package:flutter/material.dart';import 'package:flutter/widgets.dart';import 'package:fluttercontrol/page/drawguess/draw_entity.dart';import 'package:web_socket_channel/web_socket_channel.dart';import 'package:web_socket_channel/io.dart';//可选的画板颜色Map pintColor = { 'default': Color(0xFFB275F5), 'black': Colors.black, 'brown': Colors.brown, 'gray': Colors.grey, 'blueGrey': Colors.blueGrey, 'blue': Colors.blue, 'cyan': Colors.cyan, 'deepPurple': Colors.deepPurple, 'orange': Colors.orange, 'green': Colors.green, 'indigo': Colors.indigo, 'pink': Colors.pink, 'teal': Colors.teal, 'red': Colors.red, 'purple': Colors.purple, 'blueAccent': Colors.blueAccent, 'white': Colors.white,};//数据管理 WebSocket,基础数据,通讯,连接维护等( pengzhenkun - 2020.04.30 )class DrawProvider with ChangeNotifier { final String _URL = 'ws://10.10.3.55:8080/mini'; List> undoPoints = List>(); // 撤销的数据 List> points = List>(); // 存储要画的数据 List pointsList = List(); //预处理的数据,避免绘制时处理卡顿 String pentColor = "default";//默认颜色 double pentSize = 5;//默认字体大小 //Socket 连接 WebSocketChannel _channel; //开始连接 connect() { _socketConnect(); } _socketConnect() { _channel = IOWebSocketChannel.connect(_URL); _channel.stream.listen( (message) { //监听到的消息 print("收到消息:$message"); message = jsonDecode(message); if (message["type"] == "sendDraw") { //正在连续绘制 if (points.length == 0) { points.add(List()); points.add(List()); } pentColor = message["pentColor"]; pentSize = message["pentSize"]; //添加绘制 //添加绘制 points[points.length - 2].add(DrawEntity( Offset(message["dx"], message["dy"]), color: pentColor, strokeWidth: pentSize)); //通知更新 setState(); } else if (message["type"] == "sendDrawNull") { //手抬起,添加占位 //添加绘制标识 points.add(List()); //通知更新 setState(); } else if (message["type"] == "clear") { //清空画板 points.clear(); //通知更新 setState(); } else if (message["type"] == "sendDrawUndo") { //撤销,缓存到撤销容器 undoPoints.add(points[points.length - 3]); //添加到撤销的数据里 points.removeAt(points.length - 3); //移除数据 //通知更新 setState(); } else if (message["type"] == "reverseUndoDate") { //反撤销数据 List ss = undoPoints.removeLast(); points.insert(points.length - 2, ss); //通知更新 setState(); } }, onDone: () { print("连接断开 onDone"); //尝试重新连接 _socketConnect(); }, onError: (err) { print("连接异常 onError"); }, cancelOnError: true, ); } //清除数据 clear() { //清除数据 points.clear(); //通知更新 setState(); _channel.sink .add(jsonEncode({'uuid': 'xxxx', 'type': 'clear', 'msg': 'clear'})); } //绘制数据 sendDraw(Offset localPosition) { if (points.length == 0) { points.add(List()); points.add(List()); } //添加绘制 points[points.length - 2].add( DrawEntity(localPosition, color: pentColor, strokeWidth: pentSize));// points.add(localPosition); //通知更新 setState(); //发送绘制消息给服务端 _channel.sink.add(jsonEncode({ 'uuid': 'xxxx', 'type': 'sendDraw', 'pentColor': pentColor, 'pentSize': pentSize, "dx": localPosition.dx, "dy": localPosition.dy })); } //绘制Null数据隔断标识 sendDrawNull() { //添加绘制标识 points.add(List()); //通知更新 setState(); //发送绘制消息给服务端 _channel.sink.add(jsonEncode({'uuid': 'xxxx', 'type': 'sendDrawNull'})); } //撤销一条数据 undoDate() { //撤销,缓存到撤销容器 undoPoints.add(points[points.length - 3]); //添加到撤销的数据里 points.removeAt(points.length - 3); //移除数据 setState(); //发送绘制消息给服务端 _channel.sink.add(jsonEncode({'uuid': 'xxxx', 'type': 'sendDrawUndo'})); } //反撤销一条数据 reverseUndoDate() { List ss = undoPoints.removeLast(); points.insert(points.length - 2, ss); setState(); //发送绘制消息给服务端 _channel.sink.add(jsonEncode({'uuid': 'xxxx', 'type': 'reverseUndoDate'})); } @override void dispose() { _channel.sink?.close(); super.dispose(); } _update() { pointsList = List(); for (int i = 0; i < points.length - 1; i++) { pointsList.addAll(points[i]); pointsList.add(null); } } setState() { _update(); notifyListeners(); }}
- 有了以上的实现后,创建 draw_page.dart 搭建我们的主页面
import 'package:flutter/material.dart';import 'package:flutter/widgets.dart';import 'package:fluttercontrol/page/drawguess/draw_provider.dart';import 'package:fluttercontrol/page/drawguess/widget/signature_painter.dart';import 'package:provider/provider.dart';//绘制布局页面 ( pengzhenkun - 2020.04.30 )class DrawPage extends StatefulWidget { @override _DrawPageState createState() => _DrawPageState();}class _DrawPageState extends State { DrawProvider _provider = DrawProvider(); @override void initState() { super.initState(); _provider.connect(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("WebSocket Draw"), actions: [ IconButton( icon: Icon(Icons.call_missed_outgoing), onPressed: () { //撤销一步 _provider.undoDate(); }, ), IconButton( icon: Icon(Icons.call_missed), onPressed: () { //反撤销 _provider.reverseUndoDate(); }, ), ], ), body: ChangeNotifierProvider.value( value: _provider, child: Consumer( builder: (context, drawProvider, _) { return Container( color: Color(0x18262B33), child: Column( children: [ Expanded( child: Stack( children: [ Container( color: Colors.white, ), Text(drawProvider.points.length.toString()), GestureDetector( //手势探测器,一个特殊的widget,想要给一个widge添加手势,直接用这货包裹起来 onPanUpdate: (DragUpdateDetails details) { //按下 RenderBox referenceBox = context.findRenderObject(); Offset localPosition = referenceBox .globalToLocal(details.globalPosition); drawProvider.sendDraw(localPosition); }, onPanEnd: (DragEndDetails details) { drawProvider.sendDrawNull(); }, //抬起来 ), CustomPaint( painter: SignaturePainter(drawProvider.pointsList)), ], ), ), Padding( padding: EdgeInsets.only(left: 10, right: 80, bottom: 20), child: Wrap( spacing: 5, runSpacing: 5, crossAxisAlignment: WrapCrossAlignment.center, children: [ buildInkWell(drawProvider, 5), buildInkWell(drawProvider, 8), buildInkWell(drawProvider, 10), buildInkWell(drawProvider, 15), buildInkWell(drawProvider, 17), buildInkWell(drawProvider, 20), ], ), ), Padding( padding: EdgeInsets.only(left: 10, right: 80, bottom: 20), child: Wrap( spacing: 5, runSpacing: 5, children: pintColor.keys.map((key) { Color value = pintColor[key]; return InkWell( onTap: () {// setColor(context, key); drawProvider.pentColor = key; drawProvider.notifyListeners(); }, child: Container( width: 32, height: 32, color: value, child: drawProvider.pentColor == key ? Icon( Icons.done, color: Colors.white, ) : null, ), ); }).toList(), ), ) ], ), ); }, ), ), floatingActionButton: FloatingActionButton( onPressed: _provider.clear, tooltip: '', child: Icon(Icons.clear), )); } InkWell buildInkWell(DrawProvider drawProvider, double size) { return InkWell( onTap: () { drawProvider.pentSize = size; drawProvider.notifyListeners(); }, child: Container( width: 40, height: 40, child: Center( child: Container( decoration: new BoxDecoration( color: pintColor[drawProvider.pentColor], //设置四周圆角 角度 borderRadius: BorderRadius.all(Radius.circular(size / 2)), //设置四周边框 border: drawProvider.pentSize == size ? Border.all(width: 1, color: Colors.black) : null, ), width: size, height: size, ), ), ), ); } @override void dispose() { _provider.dispose(); super.dispose(); }}
- WebSocket 服务端使用 Dart 编写,没有太多逻辑,只做了数据的转发.
- 核心代码如下
//处理消息 void handMsg(dynamic msg, sct) { print('收到客户端消息:${msg}' + webSockets.length.toString()); msg = jsonDecode(msg); if (msg["type"] == "sendDraw" ||//正在连续绘制 msg["type"] == "clear" ||//清空画板 msg["type"] == "sendDrawNull" ||//手抬起,添加占位 msg["type"] == "sendDrawUndo" ||//撤销,缓存到撤销容器 msg["type"] == "reverseUndoDate")//反撤销数据 //给其它所有客户端回复当前客户端发了什么 for (WebSocket webSocket in webSockets) { //判断是否有关闭代码,如果没有证明客户端当前未关闭,给它回复 if (webSocket.closeCode == null && webSocket != sct) { //回复客户端一条消息 webSocket.add(jsonEncode(msg)); } } }
大功告成
附源码:
- Flutter:https://gitee.com/pengzhenkun/flutter_draw
- DartServer:https://gitee.com/pengzhenkun/dart_server