HarmonyOS APP《画伴梦工厂》开发第11篇:Canvas 自由涂鸦——TouchEvent 与画笔实现
第2.3篇:Canvas 自由涂鸦——TouchEvent 与画笔实现
难度:⭐⭐⭐ 高级
前置知识:第 1.2 篇 ArkUI 声明式 UI 基础
涉及源文件:products/default/src/main/ets/components/CreationComponents.ets

概述
在"画伴梦工厂"中,除了拍照和相册导入,用户还可以直接在应用内自由涂鸦创作。本文介绍如何利用 ArkUI 的 Circle 组件 替代传统 Canvas API 来实现绘图功能,这也是 HarmonyOS 声明式 UI 的一个独特思路——用组件组合替代位图操作。同时涉及 TouchEvent 事件流、橡皮擦/颜色切换、笔刷大小控制、撤销栈设计以及滚动冲突处理等完整功能。
一、ArkUI 组件化绘图:为什么不用 Canvas?
传统前端或移动端绘图通常使用 Canvas API(如 getContext('2d')、ctx.drawCircle() 等)。但在 ArkUI 中,绘图可以通过 组件组合 来实现:
核心思想:每个涂鸦点不再是一个像素操作,而是一个独立的 Circle 组件。
| 对比维度 | 传统 Canvas API | ArkUI 组件化绘图 |
|---|---|---|
| 绘图单位 | 像素/路径命令 | 组件实例(Circle) |
| 数据存储 | 位图像素数组 | 点数据结构数组 |
| 重绘方式 | draw() 刷新整个画布 |
ForEach 增量渲染 |
| 事件系统 | 坐标映射计算 | TouchEvent 直接获取 |
| 样式控制 | 状态机切换 | 声明式绑定 |
二、数据结构定义
涂鸦点的数据结构:
interface DoodlePoint {
id: number; // 唯一标识,用于 ForEach 的 key
x: number; // X 坐标
y: number; // Y 坐标
size: number; // 笔刷大小
color: string; // 颜色值(十六进制)
}
工具和颜色常量:
const DOODLE_TOOLS: string[] = ['画笔', '橡皮', '星星'];
const DOODLE_COLORS: string[] = ['#7657F3', '#FF9F43', '#4CD964', '#FF5A7A', '#32ADE6', '#1E2442'];
- 三种工具:画笔(正常绘制)、橡皮(擦除效果)、星星(保留用于扩展)
- 六种颜色:紫色、橙色、绿色、粉色、蓝色、深色,覆盖儿童绘画常用色系
三、TouchEvent 事件流详解
涂鸦画布通过 .onTouch() 绑定触摸事件:
Stack() {
Rect()
.width('100%')
.height(this.canvasHeight)
.fill(this.canvasBackground)
.radius(16)
ForEach(this.points, (point: DoodlePoint) => {
Circle()
.width(point.size)
.height(point.size)
.fill(point.color)
.position({ x: point.x - point.size / 2, y: point.y - point.size / 2 })
}, (point: DoodlePoint) => point.id.toString())
if (this.points.length === 0) {
Text('在这里画一个角色或场景')
.fontSize(13)
.fontColor('#A0A5B8')
.textAlign(TextAlign.Center)
.width('80%')
}
}
.width('100%')
.height(this.canvasHeight)
.clip(true)
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down || event.type === TouchType.Move) {
this.isDrawingOnCanvas = true;
if (event.touches.length > 0) {
this.addPoint(event.touches[0].x, event.touches[0].y);
}
} else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.isDrawingOnCanvas = false;
}
})
TouchEvent 事件类型
| 事件类型 | 触发时机 | 处理行为 |
|---|---|---|
| TouchType.Down | 手指按下屏幕 | 开始绘制,添加一个点 |
| TouchType.Move | 手指在屏幕上滑动 | 持续绘制,添加连续点 |
| TouchType.Up | 手指抬起 | 停止绘制,标记 isDrawingOnCanvas = false |
| TouchType.Cancel | 触摸被系统中断 | 同 Up,停止绘制并释放状态 |
关键实现细节
- 连续画点:
Down+Move均触发addPoint,确保手指移动时不断生成新的涂鸦点,形成连续线条。 - 事件对象:
event.touches[0]获取主触摸点坐标,支持多点触控(当前只使用第一点)。 .clip(true):裁切画布区域,防止涂鸦超出圆角矩形边界。isDrawingOnCanvas状态:标记当前是否正在绘制,供外层 Scroll 判断是否需要禁用滚动。
四、添加画笔点
addPoint 方法是涂鸦的核心逻辑:
private addPoint(x: number, y: number) {
if (x < 0 || y < 0) {
return;
}
const color: string = this.selectedTool === 1 ? this.canvasBackground : DOODLE_COLORS[this.selectedColor];
const size: number = this.selectedTool === 1 ? this.brushSize + 10 : this.brushSize;
const point: DoodlePoint = {
id: this.pointSeed++,
x: x,
y: y,
size: size,
color: color
};
this.points = this.points.concat([point]);
this.generationProgress = Math.min(72, 28 + Math.floor(this.points.length / 3));
}
橡皮擦实现原理
传统橡皮擦操作像素数据,而 ArkUI 组件化绘图中,橡皮擦通过 绘制背景色 实现:
const color: string = this.selectedTool === 1 ? this.canvasBackground : DOODLE_COLORS[this.selectedColor];
- 当
selectedTool === 1(橡皮):颜色设为画布背景色#FFFDF8 - 橡皮笔刷比普通笔刷大 10px(
brushSize + 10),擦除效果更明显
数据更新方式
使用 this.points = this.points.concat([point]) 而非 push,这触发了 ArkUI 的状态刷新机制,ForEach 会自动渲染新增的 Circle。
进度更新
this.generationProgress = Math.min(72, 28 + Math.floor(this.points.length / 3));
- 初始进度为 20(来自父组件)
- 每画 3 个点进度 +1
- 上限 72(留出后续导出和生成的进度空间)
五、工具切换 (ToolButton)
5.1 工具栏
@Builder
private ToolButton(label: string, index: number) {
Text(label)
.fontSize(12)
.fontColor(this.selectedTool === index ? '#FFFFFF' : '#6F7590')
.textAlign(TextAlign.Center)
.height(32)
.layoutWeight(1)
.backgroundColor(this.selectedTool === index ? this.brandPurple : '#F0F1F8')
.borderRadius(16)
.margin({ right: 8 })
.onClick(() => {
this.selectedTool = index;
this.noticeText = label + '已选中';
})
}
效果:三个工具按钮(画笔 | 橡皮 | 星星),选中项高亮为紫色背景白色文字。
5.2 颜色选择器
Row() {
ForEach(DOODLE_COLORS, (color: string, index: number) => {
Circle()
.width(28)
.height(28)
.fill(color)
.stroke(this.selectedColor === index ? this.ink : '#FFFFFF')
.strokeWidth(this.selectedColor === index ? 2 : 1)
.margin({ right: 9 })
.onClick(() => {
this.selectedColor = index;
this.selectedTool = 0; // 选中颜色后自动切回画笔
this.noticeText = '画笔颜色已切换';
})
}, (color: string) => color)
}
关键设计:点击颜色时自动将工具切换为画笔(this.selectedTool = 0),防止用户在橡皮模式下选择颜色后仍然保持擦除状态。
5.3 笔刷大小控制
Row() {
Text('-')
.onClick(() => {
this.brushSize = this.brushSize > 8 ? this.brushSize - 2 : 8;
})
Text('笔刷 ' + this.brushSize.toString())
Text('+')
.onClick(() => {
this.brushSize = this.brushSize < 36 ? this.brushSize + 2 : 36;
})
}
- 范围:8 ~ 36,步长 2
- 加减按钮带边界检查,防止越界
- 笔刷大小通过
@Link与父组件同步
六、撤销与清空
撤销操作
private undoPoint() {
if (this.points.length > 0) {
this.points = this.points.slice(0, this.points.length - 8 > 0 ? this.points.length - 8 : 0);
this.noticeText = '已撤销一步';
}
}
设计考量:
- 批量删除:每次撤销删除 8 个点而非 1 个点。因为在触摸滑动时,每帧会产生多个点,用户感知的"一笔"对应约 8 个连续点。单点删除会让撤销显得过于细微。
- slice 操作:创建新数组替换旧数组,触发 ArkUI 状态刷新机制。
- 边界保护:当剩余点数不足 8 个时,直接清零。
清空画布
private clearCanvas() {
this.points = [];
this.generationProgress = 20;
this.noticeText = '画布已清空';
}
重置所有涂鸦数据并将进度恢复到初始值。
七、滚动冲突处理
涂鸦场景中有一个经典问题:画布接收触摸事件时,外层 Scroll 容器不应滚动,否则会出现画一笔却被滚走的糟糕体验。
父组件中的处理
// FreeDoodlePage.ets
build() {
Scroll() {
// ...
FreeDoodleComponent({
selectedTool: $selectedTool,
selectedColor: $selectedColor,
brushSize: $brushSize,
generationProgress: $generationProgress,
noticeText: $noticeText,
isDrawingOnCanvas: $isDrawingOnCanvas
})
}
.enableScrollInteraction(!this.isDrawingOnCanvas)
}
协作机制
Touch Down/Move
│
▼
FreeDoodleComponent: isDrawingOnCanvas = true
│
▼
FreeDoodlePage: enableScrollInteraction(false)
→ Scroll 停止响应滚动手势
│
▼
Touch Up/Cancel
│
▼
FreeDoodleComponent: isDrawingOnCanvas = false
│
▼
FreeDoodlePage: enableScrollInteraction(true)
→ Scroll 恢复滚动
enableScrollInteraction 是 HarmonyOS Scroll 组件的属性,控制是否允许用户手势滚动。当用户正在画布上绘制时,该属性设为 false,避免 Scroll 与画布争抢触摸事件。
八、完整功能布局
┌──────────────────────────────────┐
│ 自由涂鸦画布 XX笔 │
├──────────────────────────────────┤
│ │
│ (涂鸦画布区域) │
│ │
│ │
├──────────────────────────────────┤
│ [ 画笔 ] [ 橡皮 ] [ 星星 ] │ ← 工具切换
├──────────────────────────────────┤
│ ● ● ● ● ● ● │ ← 颜色选择
├──────────────────────────────────┤
│ [-] 笔刷 18 [+] │ ← 笔刷大小
├──────────────────────────────────┤
│ [撤销] [清空] [用涂鸦生成] │ ← 操作按钮
└──────────────────────────────────┘
九、性能与优化考量
- 点数量限制:理论上 Circle 组件数量可以很多,但建议涂鸦点数量控制在合理范围(数千级别)。项目未显式限制,但自然绘画操作产生的点数通常在合理范围内。
- ForEach 的 key:使用
point.id.toString()作为唯一 key,确保 ArkUI 高效增量渲染而非全量重建。 - 导出为图片:
exportToImage方法将涂鸦点渲染到PixelMap并保存为 PNG 文件,用于后续动画生成。
private async exportToImage(): Promise<string> {
// 创建 720×360 像素位图
// 填充背景色
// 遍历所有 point,在像素层面绘制圆形
// 使用 ImagePacker 编码为 PNG
// 保存到沙箱目录
}
总结
本文完整实现了 ArkUI 组件化涂鸦方案:
| 知识点 | 实现方式 |
|---|---|
| 绘图方案 | Circle 组件替代 Canvas API,声明式渲染 |
| 触摸事件 | TouchType.Down/Move/Up/Cancel 事件流 |
| 橡皮擦 | 绘制背景色 + 加大笔刷 |
| 颜色切换 | 选中颜色自动切回画笔工具 |
| 笔刷控制 | 加减按钮,范围 8~36 |
| 撤销栈 | slice 批量删除(每次 8 点) |
| 滚动冲突 | enableScrollInteraction + @Link 状态同步 |
至此,"画伴梦工厂"的三种图片采集方式已全部介绍完毕:拍照采集、相册导入、自由涂鸦。每种方案适用于不同的用户场景,共同构成了完整的"收集画作"体验。
更多推荐


所有评论(0)