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: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
这种依赖结构适合做鸿蒙侧基础绘制验证:业务逻辑在 Dart 层,重点观察触控事件、画线效果、Canvas 性能和高 DPI 下的线条质量。
三、应用入口与主题配置
3.1 main 函数
Flutter 应用从 main() 启动:
import 'package:flutter/material.dart';
void main() {
runApp(const SimplePaintApp());
}
入口函数只负责加载根组件,不处理绘图状态。
3.2 根组件
class SimplePaintApp extends StatelessWidget {
const SimplePaintApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Simple Paint',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const SimplePaintHomePage(title: 'Simple Paint'),
);
}
}
根组件负责应用标题和主题配置。绘图数据、笔刷设置和交互逻辑由首页 State 维护。
3.3 主题色
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)
蓝色主题用于 AppBar 和选中态边框,与画板工具类应用的冷静风格比较匹配。
四、StatefulWidget 与画板状态
4.1 首页组件
class SimplePaintHomePage extends StatefulWidget {
const SimplePaintHomePage({super.key, required this.title});
final String title;
State<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 调色盘列表
final List<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 模型类定义
class DrawnLine {
List<Offset> points;
Color color;
double strokeWidth;
DrawnLine({
required this.points,
required this.color,
required this.strokeWidth,
});
}
每一笔由点位、颜色和粗细组成。
5.2 为什么按笔画存储
按笔画存储有几个好处:
- 撤销时可以删除最后一笔。
- 每一笔可以保留自己的颜色。
- 每一笔可以保留自己的粗细。
- 绘制时可以按笔画遍历。
5.3 数据结构关系
_lines
-> DrawnLine
-> points: List<Offset>
-> color: Color
-> strokeWidth: double
这种结构比单纯保存所有点更适合画板应用,因为它保留了每一笔的上下文。
六、手势采样流程
6.1 开始绘制
void _onPanStart(DragStartDetails details) {
setState(() {
_currentLine = [details.localPosition];
});
}
手指按下并开始拖动时,使用当前位置创建新的当前笔画。
6.2 持续追加点位
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
_currentLine.add(details.localPosition);
});
}
拖动过程中,每次更新都会把新的本地坐标加入 _currentLine。
6.3 结束绘制
void _onPanEnd(DragEndDetails details) {
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: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _colors.length,
itemBuilder: (context, index) {
final color = _colors[index];
final isSelected = _selectedColor == color;
return GestureDetector(...);
},
),
)
横向列表适合展示调色盘,不占用太多竖向空间。
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: const Icon(Icons.undo),
tooltip: 'Undo',
),
IconButton(
onPressed: _clearCanvas,
icon: const Icon(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 (final line in lines) {
final paint = 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) {
final paint = 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
bool shouldRepaint(covariant _Painter oldDelegate) {
return true;
}
当前实现每次状态变化都允许重绘,简单可靠。对于轻量画板足够,但笔画数量很大时可以进一步优化。
十四、边界场景与真实限制
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';
void main() {
testWidgets('simple paint renders toolbar', (tester) async {
await tester.pumpWidget(const SimplePaintApp());
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 {
await tester.pumpWidget(const SimplePaintApp());
await tester.tap(find.text('Eraser'));
await tester.pump();
expect(find.text('Erasing'), findsOneWidget);
});
这个测试覆盖 _isErasing 和按钮文案变化。
15.3 绘制手势测试
testWidgets('drawing gesture increases stroke count', (tester) async {
await tester.pumpWidget(const SimplePaintApp());
final gesture = await tester.startGesture(const Offset(100, 300));
await gesture.moveTo(const Offset(150, 350));
await gesture.up();
await tester.pump();
expect(find.text('1 strokes'), findsOneWidget);
});
这个测试模拟拖动,验证笔画数量是否增加。
15.4 测试命令
flutter test
保持测试中的根组件名称与实际源码一致,可以避免默认模板测试残留造成编译失败。
十六、鸿蒙适配观察
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 改成不可变模型:
class DrawnLine {
const DrawnLine({
required this.points,
required this.color,
required this.strokeWidth,
});
final List<Offset> points;
final Color color;
final double 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 绘制质量、笔刷粗细、橡皮覆盖、撤销清空和不同屏幕尺寸下的工具栏表现。处理好这些细节后,画板体验会更稳定。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐




所有评论(0)