Flutter 实战:simple_paint 手绘画板的手势采样、CustomPainter 绘制与鸿蒙适配解析
Flutter 实战:simple_paint 手绘画板的手势采样、CustomPainter 绘制与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
手绘画板是 Flutter 里非常适合练习触控、Canvas 和状态管理的项目。它不像普通表单页面那样只处理点击和输入,而是要持续采样用户手指移动轨迹,把点位组织成笔画,再交给CustomPainter绘制到画布上。
simple_paint是一个轻量画板应用,功能包括:颜色选择、笔刷粗细调节、橡皮模式、撤销最后一笔、清空画布、实时绘制当前笔画。它的实现没有复杂三方库,核心都在 Flutter 的手势系统、CustomPaint和 Dart 数据结构中,非常适合写成一篇完整的源码解析文章。
画板类应用的核心不是“画一条线”,而是如何把连续触控点、笔画状态、绘制参数和重绘时机组织清楚。
图示说明:上图展示 Flutter 页面在移动端的布局组织方式。simple_paint的实际界面由颜色调色盘、笔粗滑块、橡皮按钮、画布和撤销清空按钮组成。
一、项目定位与功能边界
1.1 应用定位
simple_paint是一个轻量手绘画板应用,用于演示 Flutter 中手势采样、线段绘制、画笔参数控制和画布状态管理。它适合学习GestureDetector、CustomPainter、Canvas.drawLine和状态驱动画面更新。
项目当前支持:
- 横向颜色调色盘。
- 选择 10 种内置颜色。
- 通过滑块调节笔刷粗细。
- 使用圆点预览当前笔刷大小。
- 橡皮模式。
- 按笔画粒度撤销。
- 清空画布。
- 显示当前已完成笔画数量。
- 实时绘制当前正在拖动的笔画。
1.2 功能模块
| 功能模块 | 页面表现 | 源码实现 |
|---|---|---|
| 调色盘 | 横向颜色圆点 | _colors+ListView.builder |
| 当前颜色 | 选中圆点蓝色边框 | _selectedColor |
| 笔刷粗细 | Slider 与预览圆点 | _strokeWidth |
| 橡皮模式 | Eraser/Erasing 按钮 | _isErasing |
| 手势采样 | 拖动画布生成线条 | _onPanStart、_onPanUpdate、_onPanEnd |
| 笔画存储 | 已完成线条列表 | _lines |
| 绘制引擎 | Canvas 上连接点位 | _Painter.paint |
| 撤销清空 | AppBar 图标按钮 | _undo()、_clearCanvas() |
1.3 技术栈
| 技术点 | 使用位置 | 价值 |
|---|---|---|
| Flutter | 页面、按钮、滑块、手势、画布 | 构建跨端画板 UI |
| Dart | 列表、模型类、状态逻辑 | 管理笔画数据 |
| Material 3 | 应用主题与控件样式 | useMaterial3: true |
| GestureDetector | 拖拽事件采样 | 获取绘制点位 |
| CustomPainter | 自定义绘制 | 在 Canvas 上画线 |
二、工程结构与运行环境
2.1 工程结构
simple_paint是标准 Flutter 工程,主逻辑位于lib/main.dart。
| 文件或目录 | 作用 |
|---|---|
lib/main.dart | 应用入口、画板状态、手势处理、绘制逻辑 |
pubspec.yaml | Flutter SDK 与测试依赖声明 |
test/widget_test.dart | Widget 测试入口 |
ohos/ | 鸿蒙平台工程目录 |
analysis_options.yaml | Dart 静态分析规则 |
2.2 运行命令
flutter doctor flutter pub get flutter run项目没有引入复杂三方绘图库,绘制能力完全来自 Flutter 自带的CustomPaint和 Canvas API。
2.3 依赖声明
dependencies:flutter:sdk:fluttercupertino_icons:^1.0.8dev_dependencies:flutter_test:sdk:flutterflutter_lints:^5.0.0这种依赖结构适合做鸿蒙侧基础绘制验证:业务逻辑在 Dart 层,重点观察触控事件、画线效果、Canvas 性能和高 DPI 下的线条质量。
三、应用入口与主题配置
3.1 main 函数
Flutter 应用从main()启动:
import'package:flutter/material.dart';voidmain(){runApp(constSimplePaintApp());}入口函数只负责加载根组件,不处理绘图状态。
3.2 根组件
classSimplePaintAppextendsStatelessWidget{constSimplePaintApp({super.key});@overrideWidgetbuild(BuildContextcontext){returnMaterialApp(title:'Simple Paint',theme:ThemeData(colorScheme:ColorScheme.fromSeed(seedColor:Colors.blue),useMaterial3:true,),home:constSimplePaintHomePage(title:'Simple Paint'),);}}根组件负责应用标题和主题配置。绘图数据、笔刷设置和交互逻辑由首页 State 维护。
3.3 主题色
colorScheme:ColorScheme.fromSeed(seedColor:Colors.blue)蓝色主题用于 AppBar 和选中态边框,与画板工具类应用的冷静风格比较匹配。
四、StatefulWidget 与画板状态
4.1 首页组件
classSimplePaintHomePageextendsStatefulWidget{constSimplePaintHomePage({super.key,requiredthis.title});finalStringtitle;@overrideState<SimplePaintHomePage>createState()=>_SimplePaintHomePageState();}画板需要持续响应用户拖拽、颜色选择、笔粗变化、橡皮切换、撤销和清空,因此使用StatefulWidget。
4.2 核心状态字段
List<DrawnLine>_lines=[];List<Offset>_currentLine=[];Color_selectedColor=Colors.black;double _strokeWidth=3.0;bool _isErasing=false;| 状态字段 | 类型 | 作用 |
|---|---|---|
_lines | List<DrawnLine> | 已完成笔画 |
_currentLine | List<Offset> | 当前正在绘制的点位 |
_selectedColor | Color | 当前画笔颜色 |
_strokeWidth | double | 当前画笔粗细 |
_isErasing | bool | 是否处于橡皮模式 |
4.3 调色盘列表
finalList<Color>_colors=[Colors.black,Colors.red,Colors.orange,Colors.yellow,Colors.green,Colors.blue,Colors.purple,Colors.pink,Colors.brown,Colors.grey,];调色盘内置 10 种颜色,覆盖常见手绘场景。
五、笔画模型 DrawnLine
5.1 模型类定义
classDrawnLine{List<Offset>points;Colorcolor;double strokeWidth;DrawnLine({requiredthis.points,requiredthis.color,requiredthis.strokeWidth,});}每一笔由点位、颜色和粗细组成。
5.2 为什么按笔画存储
按笔画存储有几个好处:
- 撤销时可以删除最后一笔。
- 每一笔可以保留自己的颜色。
- 每一笔可以保留自己的粗细。
- 绘制时可以按笔画遍历。
5.3 数据结构关系
_lines -> DrawnLine -> points: List<Offset> -> color: Color -> strokeWidth: double这种结构比单纯保存所有点更适合画板应用,因为它保留了每一笔的上下文。
六、手势采样流程
6.1 开始绘制
void_onPanStart(DragStartDetailsdetails){setState((){_currentLine=[details.localPosition];});}手指按下并开始拖动时,使用当前位置创建新的当前笔画。
6.2 持续追加点位
void_onPanUpdate(DragUpdateDetailsdetails){setState((){_currentLine.add(details.localPosition);});}拖动过程中,每次更新都会把新的本地坐标加入_currentLine。
6.3 结束绘制
void_onPanEnd(DragEndDetailsdetails){setState((){_lines.add(DrawnLine(points:List.from(_currentLine),color:_isErasing?Colors.white:_selectedColor,strokeWidth:_isErasing?_strokeWidth*3:_strokeWidth,));_currentLine=[];});}拖动结束后,把当前点位复制到新的DrawnLine中,并清空_currentLine。
七、画笔与橡皮逻辑
7.1 正常画笔
正常模式下,笔画使用当前选中颜色和当前笔刷粗细。
color:_selectedColor,strokeWidth:_strokeWidth,7.2 橡皮模式
橡皮本质上是用白色粗线覆盖已有线条。
color:_isErasing?Colors.white:_selectedColor,strokeWidth:_isErasing?_strokeWidth*3:_strokeWidth,这种实现简单直接,适合白色背景画布。
7.3 真实限制
因为橡皮是白色绘制,不是真正删除历史线段,所以如果未来画布背景改成透明、图片或其他颜色,橡皮逻辑也要同步调整。
橡皮有两种思路:一种是白色覆盖,另一种是修改历史路径。当前项目采用的是白色覆盖,简单但依赖白色画布背景。
八、调色盘实现
8.1 横向颜色列表
SizedBox(height:60,child:ListView.builder(scrollDirection:Axis.horizontal,padding:constEdgeInsets.symmetric(horizontal:16,vertical:8),itemCount:_colors.length,itemBuilder:(context,index){finalcolor=_colors[index];finalisSelected=_selectedColor==color;returnGestureDetector(...);},),)横向列表适合展示调色盘,不占用太多竖向空间。
8.2 选择颜色
onTap:(){setState((){_selectedColor=color;_isErasing=false;});}选择颜色时会自动关闭橡皮模式,避免用户以为切换颜色后仍在绘制,实际却还在擦除。
8.3 选中态
border:Border.all(color:isSelected?Colors.blue:Colors.grey.shade300,width:isSelected?3:1,)选中颜色使用更粗的蓝色边框,并显示勾选图标。
九、笔刷粗细控制
9.1 Slider 实现
Slider(value:_strokeWidth,min:1,max:20,onChanged:(value){setState((){_strokeWidth=value;});},)笔刷粗细范围是 1 到 20,适合从细线到粗线的基础绘制。
9.2 粗细预览
Container(width:_strokeWidth*2,height:_strokeWidth*2,decoration:BoxDecoration(color:_selectedColor,shape:BoxShape.circle,),)右侧圆点会随笔刷粗细变大或变小,让用户在绘制前看到当前笔刷大致效果。
9.3 交互意义
| 控制项 | 影响 |
|---|---|
| Slider | 改变_strokeWidth |
| 预览圆点 | 展示当前粗细 |
| 橡皮模式 | 实际粗细放大 3 倍 |
十、橡皮按钮与笔画计数
10.1 橡皮按钮
ElevatedButton.icon(onPressed:(){setState((){_isErasing=!_isErasing;});},icon:Icon(_isErasing?Icons.edit:Icons.auto_fix_high),label:Text(_isErasing?'Erasing':'Eraser'),style:ElevatedButton.styleFrom(backgroundColor:_isErasing?Colors.orange:Colors.grey,),)按钮文案、图标和颜色都会根据橡皮状态变化。
10.2 笔画计数
Text('${_lines.length}strokes')这里统计的是已经完成的笔画数量,不包含当前正在拖动但尚未结束的一笔。
10.3 状态表
| 状态 | 图标 | 文案 | 绘制颜色 |
|---|---|---|---|
| 画笔 | auto_fix_high | Eraser | _selectedColor |
| 橡皮 | edit | Erasing | White |
十一、撤销与清空
11.1 撤销最后一笔
void_undo(){if(_lines.isNotEmpty){setState((){_lines.removeLast();});}}撤销是按笔画粒度执行的,不是按点位粒度。
11.2 清空画布
void_clearCanvas(){setState((){_lines=[];_currentLine=[];});}清空会同时删除历史笔画和当前正在绘制的点位。
11.3 AppBar 操作
actions:[IconButton(onPressed:_undo,icon:constIcon(Icons.undo),tooltip:'Undo',),IconButton(onPressed:_clearCanvas,icon:constIcon(Icons.delete),tooltip:'Clear',),]撤销和清空放在 AppBar,符合工具类应用的常见操作习惯。
十二、CustomPainter 绘制流程
12.1 CustomPaint 接入
CustomPaint(painter:_Painter(lines:_lines,currentLine:_currentLine,currentColor:_isErasing?Colors.white:_selectedColor,currentStrokeWidth:_isErasing?_strokeWidth*3:_strokeWidth,),size:Size.infinite,)CustomPaint接收历史笔画、当前笔画和当前绘制参数。
12.2 绘制历史笔画
for(finallineinlines){finalpaint=Paint()..color=line.color..strokeWidth=line.strokeWidth..strokeCap=StrokeCap.round..style=PaintingStyle.stroke;for(int i=0;i<line.points.length-1;i++){canvas.drawLine(line.points[i],line.points[i+1],paint);}}每一笔由多个点组成,相邻点之间用drawLine连接。
12.3 绘制当前笔画
if(currentLine.isNotEmpty){finalpaint=Paint()..color=currentColor..strokeWidth=currentStrokeWidth..strokeCap=StrokeCap.round..style=PaintingStyle.stroke;for(int i=0;i<currentLine.length-1;i++){canvas.drawLine(currentLine[i],currentLine[i+1],paint);}}当前笔画单独绘制,用户拖动时可以实时看到线条。
十三、画布布局与手势区域
13.1 画布区域
Expanded(child:GestureDetector(onPanStart:_onPanStart,onPanUpdate:_onPanUpdate,onPanEnd:_onPanEnd,child:Container(decoration:BoxDecoration(color:Colors.white,border:Border.all(color:Colors.grey.shade300),),child:CustomPaint(...),),),)画布使用Expanded占据剩余空间,工具栏位于上方。
13.2 localPosition
手势回调使用details.localPosition,表示相对于当前 GestureDetector 区域的坐标。
details.localPosition这非常适合画布绘制,因为 Canvas 坐标系也基于当前绘制区域。
13.3 shouldRepaint
@overrideboolshouldRepaint(covariant_Painter oldDelegate){returntrue;}当前实现每次状态变化都允许重绘,简单可靠。对于轻量画板足够,但笔画数量很大时可以进一步优化。
十四、边界场景与真实限制
14.1 单点点击
如果只点一下不拖动,当前线条可能只有一个点。绘制逻辑通过points.length - 1控制循环,不会绘制出线段。
14.2 撤销粒度
撤销删除的是最后一条DrawnLine,也就是最后一笔。它不会撤销某一笔中的一小段。
14.3 橡皮限制
橡皮是白色覆盖,不是真正擦除历史数据。如果后续要导出透明图片或使用非白色背景,需要重新设计橡皮逻辑。
14.4 性能限制
shouldRepaint始终返回 true,笔画数量多时会反复绘制所有历史线条。简单项目可以接受,大型画板需要分层缓存或图片快照。
十五、Widget 测试设计
15.1 基础渲染测试
import'package:flutter_test/flutter_test.dart';import'../lib/main.dart';voidmain(){testWidgets('simple paint renders toolbar',(tester)async{awaittester.pumpWidget(constSimplePaintApp());expect(find.text('Simple Paint'),findsWidgets);expect(find.text('Stroke:'),findsOneWidget);expect(find.text('Eraser'),findsOneWidget);});}这个测试验证根组件和工具栏元素。
15.2 橡皮状态测试
testWidgets('eraser button toggles erasing state',(tester)async{awaittester.pumpWidget(constSimplePaintApp());awaittester.tap(find.text('Eraser'));awaittester.pump();expect(find.text('Erasing'),findsOneWidget);});这个测试覆盖_isErasing和按钮文案变化。
15.3 绘制手势测试
testWidgets('drawing gesture increases stroke count',(tester)async{awaittester.pumpWidget(constSimplePaintApp());finalgesture=awaittester.startGesture(constOffset(100,300));awaitgesture.moveTo(constOffset(150,350));awaitgesture.up();awaittester.pump();expect(find.text('1 strokes'),findsOneWidget);});这个测试模拟拖动,验证笔画数量是否增加。
15.4 测试命令
fluttertest保持测试中的根组件名称与实际源码一致,可以避免默认模板测试残留造成编译失败。
十六、鸿蒙适配观察
16.1 适配优势
simple_paint没有复杂原生插件,核心能力由 Flutter 手势和 Canvas 完成,适合验证鸿蒙侧基础绘制能力。
| 维度 | 当前项目情况 | 鸿蒙侧关注点 |
|---|---|---|
| 触控输入 | GestureDetector | 拖动采样密度和延迟 |
| 绘制能力 | CustomPainter | 线条质量和重绘性能 |
| 工具栏 | ListView、Slider、按钮 | 控件可点击性 |
| 橡皮模式 | 白色粗线覆盖 | 背景一致性 |
| 撤销清空 | 列表状态变化 | 重绘后画布同步 |
16.2 构建命令参考
flutter clean flutter pub get flutter build hap具体命令取决于所使用的鸿蒙 Flutter 适配环境。这个项目主要验证拖拽、画线、笔刷、橡皮和撤销清空。
16.3 运行验证要点
- 应用能正常启动到画板页面。
- 手指拖动画布可以连续绘线。
- 调色盘选色后新笔画颜色正确。
- Slider 调整后笔刷粗细变化。
- 橡皮模式能以白色粗线覆盖内容。
- 撤销和清空后画布状态正确刷新。
鸿蒙适配中,画板类应用要重点观察触控事件连续性、Canvas 重绘性能、线条圆角效果和高刷新拖动时的延迟。
十七、性能与可维护性
17.1 性能特征
项目当前适合轻量绘制,笔画数量少时性能压力不大。
| 维度 | 当前表现 |
|---|---|
| 绘制方式 | 每次重绘遍历历史笔画 |
| 当前笔画 | 实时绘制 |
| 撤销粒度 | 一整笔 |
| 橡皮方式 | 白色覆盖 |
| 重绘策略 | shouldRepaint始终 true |
17.2 当前结构优点
- 手势采样方法职责清楚。
- 笔画模型保留颜色和粗细。
- 当前笔画和历史笔画分开管理。
- 撤销逻辑简单稳定。
- 工具栏、画布和绘制类边界清晰。
17.3 可演进方向
可以把DrawnLine改成不可变模型:
classDrawnLine{constDrawnLine({requiredthis.points,requiredthis.color,requiredthis.strokeWidth,});finalList<Offset>points;finalColorcolor;finaldouble strokeWidth;}不可变模型更适合复杂画板状态管理,也能减少意外修改历史笔画。
十八、常见问题与优化建议
18.1 为什么用CustomPainter
因为画板需要直接在 Canvas 上绘制线段。普通 Widget 更适合布局和控件,CustomPainter更适合点、线、路径这类自定义图形。
18.2 为什么用相邻点连线
拖动过程中采样到的是一串离散点。把相邻点用drawLine连接,就能形成连续笔画。
18.3 为什么橡皮使用白色
当前画布背景是白色,所以白色粗线可以达到擦除视觉效果。这个实现简单,但依赖白色背景。
18.4 为什么撤销只删最后一笔
笔画按DrawnLine保存,因此撤销最自然的粒度是一笔。要实现更细粒度撤销,需要改变数据结构。
18.5 为什么shouldRepaint返回 true
画布状态频繁变化,始终重绘能保证显示正确。轻量项目可以这样写,复杂项目再做重绘优化。
18.6 为什么适合做鸿蒙适配示例
它覆盖了触控采样、Canvas 绘制、滑块、横向调色盘、按钮和状态刷新,能很好验证 Flutter 画板类应用在鸿蒙侧的基础表现。
总结
simple_paint用一个 Flutter 页面实现了轻量手绘画板的完整闭环:GestureDetector采集拖拽点位,DrawnLine保存每一笔的点、颜色和粗细,CustomPainter在 Canvas 上按相邻点连线绘制,工具栏负责颜色、粗细、橡皮、撤销和清空。
从工程角度看,这个项目的结构很适合学习 Flutter 自定义绘制。它把当前笔画和历史笔画分开管理,把绘制逻辑集中到_Painter,让状态和渲染边界比较清楚。
从鸿蒙适配角度看,重点是验证拖拽采样连续性、Canvas 绘制质量、笔刷粗细、橡皮覆盖、撤销清空和不同屏幕尺寸下的工具栏表现。处理好这些细节后,画板体验会更稳定。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
- Flutter 官方文档
- Flutter 测试文档
- OpenHarmony 官网
