一、应用概述

手势绘制(Drawing Canvas) 是一款手指涂鸦画板应用。用户可以在画布上通过手指滑动自由绘制线条,支持 8 种颜色选择和 3 种笔触大小切换,并提供了橡皮擦擦除和清空画布功能。

在当前版本的 HarmonyOS 中,虽然没有像 Web 端 Canvas API 那样提供 pathlineTo 等绘图上下文,但通过 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') 描边高亮。点击颜色时:

  1. currentColor 设为该颜色
  2. 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 组件被用作三个不同角色:

  1. 颜色选择器:28×28 的色块圆
  2. 绘制点:动态生成的涂鸦点
  3. 选中状态指示:描边高亮

同一组件在不同上下文中扮演不同视觉角色,体现了组件复用的灵活性。

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 数组达到数千个点时,性能可能下降。优化策略:

  1. 点采样:只在 Move 事件中间隔采样(每 3 个事件记录 1 个),而不是每个事件都记录
  2. 最大点数限制:当点数超过阈值时,移除最早的点
  3. 离屏渲染:使用 Canvas 组件(如果可用)替代点阵式渲染

5.4 坐标偏移问题

event.touches[0].x/y 返回的是相对于被触摸组件的坐标。如果画布在页面中不是从 (0,0) 开始,坐标可能需要调整。当前代码中,画布是页面中居中的子组件,坐标是相对于画布本身的,因此不需要额外调整。

六、可扩展思路

  1. Canvas 组件替代:如果 HarmonyOS 版本支持 Canvas 组件,可以使用 CanvasRenderingContext2D 绘图,性能更好且支持线条平滑。
  2. 撤销/重做:维护一个操作历史栈,支持 Ctrl+Z 撤销。
  3. 保存到相册:将画布内容导出为图片并保存到系统相册。
  4. 线条平滑:在触摸点之间进行插值(如贝塞尔曲线),使绘制线条更平滑。
  5. 文字输入:支持在画布上添加文字标签。

七、总结

手势绘制应用以 99 行代码,实现了一个功能完整的涂鸦画板。它的技术亮点包括:

  • onTouch 事件处理:Down 和 Move 事件的组合使用实现连续绘制
  • 点阵式绘图:用 Circle + position 模拟画笔绘制
  • 橡皮擦算法:基于距离阈值的点移除算法
  • 工具切换:画笔/橡皮擦的 toggle 模式切换
  • 颜色选择器:ForEach 渲染的颜色圆形选择面板
  • 多级笔触:4/8/16 三档笔触大小切换

虽然当前代码存在点阵渲染缺失的问题,但整个设计思路——触摸事件收集 + 数据驱动渲染——是完全正确的。这种模式可以扩展到签名板、笔记涂鸦、儿童绘画等各类手写应用中。

下一期我们将进入一个更有趣的领域——弹弹球(Bouncing Ball),一个基于物理模拟的休闲小游戏,看如何用代码实现碰撞检测和重力反弹效果。


本文是 HarmonyOS 应用开发实战系列第 39 期,更多内容请关注专栏。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐