鸿蒙原生应用实战(十)ArkUI 涂鸦画板:Canvas 绘图 + 颜色选择 + 笔画管理 + 导出

🎨 鸿蒙原生应用实战(十)ArkUI 涂鸦画板:Canvas 绘图 + 颜色选择 + 笔画管理 + 导出

博主说: 从儿童涂鸦到专业绘图,画板应用覆盖了各种用户群体。今天我们用 ArkUI 的 Canvas 2D API,从零实现一个支持自由手绘、颜色切换、笔画粗细、撤销重做、导出图片的完整涂鸦画板


📱 应用场景

场景 说明
✏️ 随手涂鸦 用手指在屏幕上画画
📝 课堂笔记 用手写笔做批注
🖼️ 图片标注 截图后标记重点
🧒 儿童绘画 彩色画笔自由创作

⚙️ 运行环境要求

项目 版本要求
DevEco Studio 5.0.3.800+
HarmonyOS SDK API 12
核心 API Canvas 2D + @ohos.multimedia.image
权限 无特殊权限

🛠️ 实战:从零搭建涂鸦画板

Step 1:画板核心架构

复制代码
触摸事件 (PanGesture)
    ↓ 记录轨迹点
路径列表 (paths: PathData[])
    ↓ 逐个绘制到 Canvas
画布渲染
    ↓ 
撤销: 删除最后一条路径
重做: 恢复删除的路径
清除: 清空所有路径
导出: ImagePacker 编码为图片

Step 2:完整代码

typescript 复制代码
// pages/Index.ets --- 涂鸦画板
import image from '@ohos.multimedia.image';
import fileIo from '@ohos.file.fs';

interface Point { x: number; y: number; }

interface StrokeData {
  points: Point[];      // 轨迹点
  color: string;         // 颜色
  width: number;         // 粗细
  opacity: number;       // 透明度
}

@Entry
@Component
struct DoodlePad {
  private ctx!: CanvasRenderingContext2D;

  @State strokes: StrokeData[] = [];
  @State undoneStrokes: StrokeData[] = [];
  @State currentColor: string = '#007AFF';
  @State currentWidth: number = 4;
  @State currentOpacity: number = 1;
  @State currentPoints: Point[] = [];
  @State isDrawing: boolean = false;
  @State brushType: 'pen' | 'marker' | 'eraser' = 'pen';
  @State canvasWidth: number = 360;
  @State canvasHeight: number = 500;

  private colors: string[] = ['#FF3B30','#FF9500','#FFCC00','#34C759','#007AFF','#5856D6','#AF52DE','#000000','#888888','#FFFFFF'];
  private widths: number[] = [2, 4, 8, 12, 20];
  private canvasUpdateId: number = 0;

  // ======== 开始绘制 ========
  onDrawStart(event: GestureEvent) {
    this.isDrawing = true;
    const x = event.fingerInfo[0]?.x || 0;
    const y = event.fingerInfo[0]?.y || 0;
    this.currentPoints = [{ x, y }];

    // 绘制起点
    this.ctx.beginPath();
    this.ctx.arc(x, y, this.currentWidth / 2, 0, Math.PI * 2);
    this.ctx.fillStyle = this.brushType === 'eraser' ? '#FFFFFF' : this.currentColor;
    this.ctx.fill();
  }

  // ======== 绘制中 ========
  onDrawMove(event: GestureEvent) {
    if (!this.isDrawing) return;
    const x = event.fingerInfo[0]?.x || 0;
    const y = event.fingerInfo[0]?.y || 0;
    this.currentPoints.push({ x, y });

    const prev = this.currentPoints[this.currentPoints.length - 2];
    if (!prev) return;

    this.ctx.beginPath();
    this.ctx.moveTo(prev.x, prev.y);
    this.ctx.lineTo(x, y);
    this.ctx.strokeStyle = this.brushType === 'eraser' ? '#FFFFFF' : this.currentColor;
    this.ctx.lineWidth = this.currentWidth;
    this.ctx.lineCap = 'round';
    this.ctx.lineJoin = 'round';
    this.ctx.globalAlpha = this.brushType === 'eraser' ? 1 : this.currentOpacity;
    this.ctx.stroke();
    this.ctx.globalAlpha = 1;
  }

  // ======== 结束绘制 ========
  onDrawEnd() {
    if (this.currentPoints.length < 2) return;
    
    this.strokes.push({
      points: [...this.currentPoints],
      color: this.brushType === 'eraser' ? '#FFFFFF' : this.currentColor,
      width: this.currentWidth,
      opacity: this.brushType === 'eraser' ? 1 : this.currentOpacity
    });

    this.currentPoints = [];
    this.isDrawing = false;
    this.undoneStrokes = []; // 新笔画清除重做栈
  }

  // ======== 撤销 ========
  undo() {
    if (this.strokes.length === 0) return;
    const last = this.strokes.pop()!;
    this.undoneStrokes.push(last);
    this.redrawAll();
  }

  // ======== 重做 ========
  redo() {
    if (this.undoneStrokes.length === 0) return;
    const stroke = this.undoneStrokes.pop()!;
    this.strokes.push(stroke);
    this.redrawAll();
  }

  // ======== 清除全部 ========
  clearAll() {
    this.undoneStrokes.push(...this.strokes);
    this.strokes = [];
    this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
  }

  // ======== 重绘所有笔画 ========
  redrawAll() {
    this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    for (const stroke of this.strokes) {
      if (stroke.points.length < 2) continue;
      this.ctx.beginPath();
      this.ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
      for (let i = 1; i < stroke.points.length; i++) {
        this.ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
      }
      this.ctx.strokeStyle = stroke.color;
      this.ctx.lineWidth = stroke.width;
      this.ctx.lineCap = 'round';
      this.ctx.lineJoin = 'round';
      this.ctx.globalAlpha = stroke.opacity;
      this.ctx.stroke();
      this.ctx.globalAlpha = 1;
    }
  }

  // ======== 导出图片 ========
  async exportImage() {
    try {
      // Canvas 转 PixelMap
      const pixelMap = await this.ctx.getPixelMap(0, 0, this.canvasWidth, this.canvasHeight);
      const packer = image.createImagePacker();
      const packed = await packer.packing(pixelMap, { format: 'image/png', quality: 100 });

      const path = getContext(this).filesDir + `/doodle_${Date.now()}.png`;
      const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
      fileIo.writeSync(file.fd, packed.data);
      fileIo.closeSync(file);

      AlertDialog.show({ message: `✅ 已保存到: ${path}` });
    } catch (err) {
      AlertDialog.show({ message: '导出失败: ' + JSON.stringify(err) });
    }
  }

  // ======== UI 构建 ========
  build() {
    Column() {
      // 顶部工具栏
      Row() {
        Button('↩').fontSize(18).backgroundColor('transparent').fontColor('#333')
          .onClick(() => { this.undo(); })
        Button('↪').fontSize(18).backgroundColor('transparent').fontColor('#333')
          .onClick(() => { this.redo(); })
        Button('🗑️').fontSize(16).backgroundColor('transparent').fontColor('#FF3B30')
          .onClick(() => { this.clearAll(); })
        Text('🎨').fontSize(20)
        Button('📤').fontSize(16).backgroundColor('transparent').fontColor('#007AFF')
          .onClick(() => { this.exportImage(); })
      }
      .width('100%').justifyContent(FlexAlign.SpaceEvenly).padding(8)
      .backgroundColor('#F8F9FA')

      // 画布
      Canvas(this.ctx)
        .width(this.canvasWidth).height(this.canvasHeight)
        .backgroundColor('#FFFFFF')
        .border({ width: 1, color: '#E0E0E0' })
        .gesture(
          PanGesture({ distance: 1 })
            .onActionStart((e) => { this.onDrawStart(e); })
            .onActionUpdate((e) => { this.onDrawMove(e); })
            .onActionEnd(() => { this.onDrawEnd(); })
        )

      // 颜色选择器
      Row() {
        ForEach(this.colors, (color: string) => {
          Circle().width(28).height(28)
            .fill(color)
            .stroke(this.currentColor === color ? '#333' : 'transparent')
            .strokeWidth(3)
            .onClick(() => { this.currentColor = color; this.brushType = 'pen'; })
        })
      }
      .width('100%').justifyContent(FlexAlign.Center).gap(6).padding(8)

      // 粗细 + 透明度
      Row() {
        ForEach(this.widths, (w: number) => {
          Circle().width(Math.max(16, w * 2)).height(Math.max(16, w * 2))
            .fill(this.currentWidth === w ? this.currentColor : '#ddd')
            .onClick(() => { this.currentWidth = w; })
        })
        Text('🖊️').fontSize(20).onClick(() => { this.brushType = 'pen'; })
        Text('🖌️').fontSize(20).onClick(() => { this.brushType = 'marker'; this.currentWidth = 12; })
        Text('🧹').fontSize(20).onClick(() => { this.brushType = 'eraser'; this.currentWidth = 20; })
      }
      .width('100%').justifyContent(FlexAlign.Center).gap(8).padding({ bottom: 8 })
    }
    .width('100%').height('100%').backgroundColor('#fff')
  }
}

官方文档: HarmonyOS 应用开发文档

相关推荐
祭曦念1 小时前
【共创季稿事节】鸿蒙MediaQueryListener布局实战
华为·harmonyos·媒体
不羁的木木2 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第五篇:综合实战——打造自适应阅读器
华为·harmonyos
金启攻2 小时前
鸿蒙原生应用开发实战(三):数据管理与多页面交互——渔获记录、装备管理与个人中心
harmonyos
伶俜662 小时前
鸿蒙原生应用实战(九)ArkUI 天气预报 App:HTTP 请求 + 定位 + 动效
http·华为·harmonyos
伶俜662 小时前
鸿蒙原生应用实战(四)ArkUI 语音变声器:录音 + 4 种音效 + 音调变换算法
算法·华为·harmonyos
HwJack202 小时前
HarmonyOS APP开发终结“户外运动数据失踪”的玄学:玩透穿戴设备 P2P 穿透与心跳保活的心法
华为·harmonyos·p2p
芒鸽2 小时前
HarmonyOS 网络编程实战:HTTP、WebSocket 与 Socket 通信详解
网络·http·harmonyos
风满城332 小时前
鸿蒙原生应用实战(二):数独游戏核心逻辑开发 — 棋盘渲染与交互
harmonyos