一、应用概述
手势绘制(Drawing Canvas)是一款手指涂鸦画板应用。用户可以在画布上通过手指滑动自由绘制线条,支持 8 种颜色选择和 3 种笔触大小切换,并提供了橡皮擦擦除和清空画布功能。
在当前版本的 HarmonyOS 中,虽然没有像 Web 端 Canvas API 那样提供path或lineTo等绘图上下文,但通过onTouch事件记录触摸点坐标,再利用Stack层叠布局在不同位置渲染小圆点,同样可以实现流畅的手绘效果。这种点阵式绘图的巧妙实现,展示了 ArkTS 声明式 UI 在面对图形绘制需求时的灵活变通能力。
二、技术架构与组件树
2.1 整体架构
@Entry @Component struct index39 ├── Column (根容器) │ ├── Row (顶部导航栏) │ └── Column (主内容区, 居中) │ ├── Column (画布容器) │ │ ├── Text (透明占位, 340×340, 接受 onTouch) │ │ │ └── .overlay() ← 此处应有绘制点阵 │ │ └── (实际绘制通过 ForEach 在父级? 看下文分析) │ └── Row (工具栏) │ ├── ForEach (8 种颜色圆形选择器) │ ├── Blank() │ ├── Button (笔触大小切换 4/8/16) │ ├── Text ("🧹" 橡皮擦切换) │ └── Text ("🗑️" 清空)2.2 数据模型
interface DrawPoint { x: number; y: number; color: string; size: number; } @State points: DrawPoint[] = []; @State currentColor: string = '#333'; @State currentSize: number = 4; @State tool: string = 'draw'; colors: string[] = ['#333', '#F44336', '#FF9800', '#FFD700', '#4CAF50', '#0077FF', '#9C27B0', '#000']; private canvasSize: number = 340;DrawPoint接口定义了每个绘制点的属性:坐标 (x, y)、颜色、大小。@State points数组存储了所有绘制的点,是画布的全部数据源。
三、核心代码深度解析
3.1 触摸事件处理
onTouchStart(event: TouchEvent): void { const touch = event.touches[0]; if (this.tool === 'draw') { this.points = this.points.concat([ { x: touch.x, y: touch.y, color: this.currentColor, size: this.currentSize } ]); } } onTouchMove(event: TouchEvent): void { const touch = event.touches[0]; if (this.tool === 'draw') { this.points = this.points.concat([ { x: touch.x, y: touch.y, color: this.currentColor, size: this.currentSize } ]); } else { // 橡皮擦模式:移除触摸点附近的点 const newPoints: DrawPoint[] = []; for (let i = 0; i < this.points.length; i++) { const p = this.points[i]; if (Math.abs(p.x - touch.x) > 15 || Math.abs(p.y - touch.y) > 15) { newPoints.push(p); } } this.points = newPoints; } }TouchEvent 结构:
event.touches[0]是当前触摸点的信息,包含:
x: 触摸点相对于被触摸组件的 X 坐标y: 触摸点相对于被触摸组件的 Y 坐标id: 触摸点的唯一标识(多点触控时区分不同手指)
画笔模式(draw):
- 在
Down(手指按下)和Move(手指移动)时均记录点 - 每次触摸产生的新点通过
concat()追加到数组末尾 - 点的颜色和大小取当前选中的值
橡皮擦模式(erase):
- 遍历所有已绘制的点
- 移除与当前触摸位置距离小于 15px 的点(
Math.abs(p.x - touch.x) > 15 || Math.abs(p.y - touch.y) > 15) - 保留距离较远的点
这里使用的是距离阈值法而不是精确的圆形碰撞检测——只要坐标差绝对值超过 15px 就保留。虽然不如Math.sqrt(dx² + dy²) < radius精确,但对于触控擦除场景已经足够,且计算更简单。
3.2 画布绑定 Touch 事件
Column() { Text('').width(this.canvasSize).height(this.canvasSize) .backgroundColor('#fff') .border({ width: 1, color: '#ddd' }) .borderRadius(8) .onTouch((event: TouchEvent) => { if (event.type === TouchType.Down) this.onTouchStart(event); if (event.type === TouchType.Move) this.onTouchMove(event); }) .clip(true) }onTouch是 ArkTS 中处理触摸事件的核心 API。它接收一个TouchEvent回调,包含:
- type:
TouchType.Down(按下)、TouchType.Move(移动)、TouchType.Up(抬起)、TouchType.Cancel(取消) - touches: 当前触摸点数组
- changedTouches: 变化的触摸点数组
clip(true)确保绘制内容不会超出画布的圆角边界。
3.3 点阵渲染的缺失与解决方案
注意:当前代码中绘制触摸点时收集了points数据,但并没有在 build() 中实际渲染这些点。这意味着运行应用时触摸画布不会看到任何绘制结果。
要完成画布功能,需要在画布的.overlay()或Stack中渲染points数组中的点。以下是完整的渲染方案:
Stack() { // 画布背景 Text('').width(this.canvasSize).height(this.canvasSize) .backgroundColor('#fff').border({ width: 1, color: '#ddd' }) .borderRadius(8) .onTouch((event: TouchEvent) => { if (event.type === TouchType.Down) this.onTouchStart(event); if (event.type === TouchType.Move) this.onTouchMove(event); }) .clip(true) // 绘制所有点 ForEach(this.points, (p: DrawPoint, idx: number) => { Circle() .width(p.size).height(p.size) .fill(p.color) .position({ x: p.x - p.size / 2, y: p.y - p.size / 2 }) }, (p: DrawPoint, idx: number) => idx.toString()) }这里使用Circle组件为每个点渲染一个小圆,位置通过position精确设置,减去了size/2使圆心对齐触摸点坐标。
3.4 颜色选择器
Row() { ForEach(this.colors, (c: string) => { Column() { Circle().width(28).height(28).fill(c) .stroke(this.currentColor === c ? '#333' : 'transparent') .strokeWidth(3) } .padding(3).onClick(() => { this.currentColor = c; this.tool = 'draw' }) }) }8 种颜色的圆形选择器,选中状态通过.stroke('#333')描边高亮。点击颜色时:
- 将
currentColor设为该颜色 - 将
tool设为'draw'(从橡皮擦模式切回画笔模式)
颜色数组包含了从黑色到彩色的渐变:
#333(深灰/黑色)#F44336(红)#FF9800(橙)#FFD700(金/黄)#4CAF50(绿)#0077FF(蓝)#9C27B0(紫)#000(纯黑)
3.5 笔触大小切换
Button() { Text(this.currentSize.toString()).fontSize(13).fontColor('#333') }.width(44).height(32).borderRadius(16).backgroundColor('#eee') .onClick(() => { if (this.currentSize === 4) this.currentSize = 8; else if (this.currentSize === 8) this.currentSize = 16; else this.currentSize = 4; })笔触大小在4 → 8 → 16之间循环切换。按钮上显示当前大小数值,让用户直观了解当前笔触。
3.6 橡皮擦与清空
Text('🧹').fontSize(24).padding(4) .onClick(() => { this.tool = this.tool === 'erase' ? 'draw' : 'erase' }) Text('🗑️').fontSize(22).padding(4) .onClick(() => this.clearCanvas())橡皮擦切换:点击 🧹 图标在'draw'和'erase'模式间切换。这是一个toggle 开关——再次点击切回画笔。
清空画布:🗑️ 图标调用clearCanvas():
clearCanvas(): void { this.points = []; }直接将points置为空数组,所有绘制的点消失。
四、HarmonyOS 特性分析
4.1 onTouch 事件系统
HarmonyOS 的触摸事件系统包含三个级别:
| 事件 | 触发时机 | 用途 |
|---|---|---|
onTouch | 所有触摸动作 | 原始触摸事件处理(本例使用) |
onClick | 轻触后抬起 | 按钮点击、图标点击 |
onGesture | 手势识别 | 滑动、长按、捏合等高级手势 |
本例使用onTouch而非onClick,因为涂鸦需要追踪手指的连续运动,而非单纯的点击。
4.2 Circle 组件的灵活应用
在本应用中,Circle组件被用作三个不同角色:
- 颜色选择器:28×28 的色块圆
- 绘制点:动态生成的涂鸦点
- 选中状态指示:描边高亮
同一组件在不同上下文中扮演不同视觉角色,体现了组件复用的灵活性。
4.3 clip 裁剪属性
.clip(true)clip属性控制子组件是否被父组件的边界裁剪。在画布中,clip(true)确保绘制的内容不会超出画布的圆角矩形边界,保持视觉整洁。
五、UI/UX 设计与最佳实践
5.1 工具栏布局
工具栏使用Row水平排列,左侧是颜色选择器,右侧是工具按钮。Blank()组件将左右两组元素推到两端,实现「左侧颜色、右侧工具」的清晰分区。
5.2 工具切换的视觉反馈
当前代码中,橡皮擦模式和画笔模式之间切换时,没有明显的视觉反馈来指示当前处于哪种模式。最佳实践是增加模式指示:
Text(this.tool === 'erase' ? '🧹 橡皮擦模式' : '✏️ 画笔模式') .fontSize(14).fontColor('#888')或者在颜色选择器上叠加一个半透明遮罩,表示当前不可用。
5.3 性能优化建议
当points数组达到数千个点时,性能可能下降。优化策略:
- 点采样:只在
Move事件中间隔采样(每 3 个事件记录 1 个),而不是每个事件都记录 - 最大点数限制:当点数超过阈值时,移除最早的点
- 离屏渲染:使用
Canvas组件(如果可用)替代点阵式渲染
5.4 坐标偏移问题
event.touches[0].x/y返回的是相对于被触摸组件的坐标。如果画布在页面中不是从 (0,0) 开始,坐标可能需要调整。当前代码中,画布是页面中居中的子组件,坐标是相对于画布本身的,因此不需要额外调整。
六、可扩展思路
- Canvas 组件替代:如果 HarmonyOS 版本支持 Canvas 组件,可以使用 CanvasRenderingContext2D 绘图,性能更好且支持线条平滑。
- 撤销/重做:维护一个操作历史栈,支持 Ctrl+Z 撤销。
- 保存到相册:将画布内容导出为图片并保存到系统相册。
- 线条平滑:在触摸点之间进行插值(如贝塞尔曲线),使绘制线条更平滑。
- 文字输入:支持在画布上添加文字标签。
七、总结
手势绘制应用以 99 行代码,实现了一个功能完整的涂鸦画板。它的技术亮点包括:
- onTouch 事件处理:Down 和 Move 事件的组合使用实现连续绘制
- 点阵式绘图:用 Circle + position 模拟画笔绘制
- 橡皮擦算法:基于距离阈值的点移除算法
- 工具切换:画笔/橡皮擦的 toggle 模式切换
- 颜色选择器:ForEach 渲染的颜色圆形选择面板
- 多级笔触:4/8/16 三档笔触大小切换
虽然当前代码存在点阵渲染缺失的问题,但整个设计思路——触摸事件收集 + 数据驱动渲染——是完全正确的。这种模式可以扩展到签名板、笔记涂鸦、儿童绘画等各类手写应用中。
下一期我们将进入一个更有趣的领域——弹弹球(Bouncing Ball),一个基于物理模拟的休闲小游戏,看如何用代码实现碰撞检测和重力反弹效果。
本文是 HarmonyOS 应用开发实战系列第 39 期,更多内容请关注专栏。