第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,停止绘制并释放状态

关键实现细节

  1. 连续画点Down + Move 均触发 addPoint,确保手指移动时不断生成新的涂鸦点,形成连续线条。
  2. 事件对象event.touches[0] 获取主触摸点坐标,支持多点触控(当前只使用第一点)。
  3. .clip(true):裁切画布区域,防止涂鸦超出圆角矩形边界。
  4. 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    [+]            │  ← 笔刷大小
├──────────────────────────────────┤
│  [撤销]  [清空]  [用涂鸦生成]     │  ← 操作按钮
└──────────────────────────────────┘

九、性能与优化考量

  1. 点数量限制:理论上 Circle 组件数量可以很多,但建议涂鸦点数量控制在合理范围(数千级别)。项目未显式限制,但自然绘画操作产生的点数通常在合理范围内。
  2. ForEach 的 key:使用 point.id.toString() 作为唯一 key,确保 ArkUI 高效增量渲染而非全量重建。
  3. 导出为图片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 状态同步

至此,"画伴梦工厂"的三种图片采集方式已全部介绍完毕:拍照采集相册导入自由涂鸦。每种方案适用于不同的用户场景,共同构成了完整的"收集画作"体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐