鸿蒙原生应用实战(十)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 小时前
【补能雷达 Skill|20】项目复盘与升级路线:从 Web Demo 到真正的车主补能助手
harmonyos·ai智能体·高德开放平台·高德skill
国服第二切图仔5 小时前
HarmonyOS APP《画伴梦工厂》开发第38篇-自适应布局API实战——adaptiveLayout模块
华为·harmonyos
特立独行的猫A5 小时前
HarmonyOS鸿蒙原生包HNP全解析:从规范到实战的完整指南
harmonyos
nashane9 小时前
HarmonyOS 6商城开发学习:剪贴板权限频繁弹窗的根治——从“自动嗅探“改为“用户主动触发“模型
华为·harmonyos
国服第二切图仔9 小时前
HarmonyOS APP《画伴梦工厂》开发第37篇-GridRow-GridCol——响应式网格布局
华为·harmonyos
痕忆丶9 小时前
openharmony开发基础之5.0.1版本文件管理器复制粘贴框架调用流程
harmonyos
国服第二切图仔10 小时前
HarmonyOS APP《画伴梦工厂》开发第31篇-语音识别实战——SpeechRecognitionEngine+AudioCapturer
语音识别·xcode·harmonyos
TrisighT12 小时前
Electron 鸿蒙 PC 上点外链唤醒应用,我试了 6 种写法只有 1 种能跑
前端·electron·harmonyos
TrisighT13 小时前
Electron 跑鸿蒙 PC 上,这 4 个 API 的行为跟 Windows 完全不一样——但文档一行都没写
windows·electron·harmonyos