如何在Canvas上实现涂抹效果
================

#### 问题现象

在移动应用开发中，屏幕涂抹交互（如签名、涂鸦、擦除）是提升用户体验的关键功能。HarmonyOS的Canvas组件结合触摸事件监听与手势识别，为开发者提供了低门槛、高性能的2D图形绘制能力。本文将通过以下常见应用场景，详解如何在Canvas上实现涂抹效果：

*   场景一：如何结合手势或者事件实现滑动路径绘制？
*   场景二：如何撤销已绘制的路径？
*   场景三：如何擦除部分绘制内容？

#### 背景知识

*   [Canvas](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-drawing-customization-on-canvas)：提供画布组件，用于自定义绘制图形，开发者使用CanvasRenderingContext2D对象和OffscreenCanvasRenderingContext2D对象在Canvas组件上进行绘制，绘制对象可以是基础形状、文本、图片等。
*   [lineTo](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-canvasrenderingcontext2d#lineto)：从当前点到指定点进行路径连接。
*   [globalCompositeOperation](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-canvasrenderingcontext2d#globalcompositeoperation)：设置合成操作的方式，默认值为source-over。
*   [onTouch](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-universal-events-touch#ontouch)：手指触摸动作触发该回调。可以获取滑动过的路径坐标点。
*   [PanGesture](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-basic-gestures-pangesture)：滑动手势事件，当滑动的最小距离达到设定的最小值时触发滑动手势事件。

#### 解决方案

*   场景一：结合手势或者事件实现路径绘制。
    
    Canvas组件可以绑定触摸事件和滑动手势来获取手指按压时的坐标，在事件触发过程中可以根据event对象获取到在屏幕上触摸的点，再结合Canvas的lineTo方法就可以把手指移动过程中的路径给记录下来，达到手指滑动屏幕就绘制的效果。
    
    *   onTouch实现如下：
        
        @Entry
        @Component
        struct CanvasTouch {
          private settings: RenderingContextSettings = new RenderingContextSettings(true);
          private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
        
        
          build() {
            Column() {
              Canvas(this.context)
                .width('100%')
                .height('100%')
                .backgroundColor($r('sys.color.comp\_background\_focus'))
                .onReady(() => {
                  this.context.lineWidth = 10;
                  this.context.strokeStyle = '#0000ff';
                })
                .onTouch((event: TouchEvent) => {
                 _ // 获取到触摸的坐标点_
                  let x = event.touches\[0\].x;
                  let y = event.touches\[0\].y;
                  if (event.type == TouchType.Down) {
                  _  // 手指按下时画布移动到当前坐标点_
                    this.context.beginPath();
                    this.context.moveTo(x, y);
                  }
                  if (event.type === TouchType.Move) {
                  _  // 手指移动时画布用线条连接到当前坐标点_
                    this.context.lineTo(x, y);
                    this.context.stroke();
                  }
                  if (event.type === TouchType.Up) {
                   _ // 手指抬起时生成闭合路径_
                    this.context.closePath();
                  }
                })
                .expandSafeArea(\[SafeAreaType.SYSTEM\], \[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM\]);
            };
          }
        }
        
    *   PanGesture实现如下：
        
        @Entry
        @Component
        struct CanvasPanGesture {
          private settings: RenderingContextSettings = new RenderingContextSettings(true);
          private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
        
        
          build() {
            Column() {
              Canvas(this.context)
                .width('100%')
                .height('100%')
                .backgroundColor($r('sys.color.comp\_background\_focus'))
                .onReady(() => {
                  this.context.lineWidth = 10;
                  this.context.strokeStyle = '#0000ff';
                })
                .gesture(
                  PanGesture({ fingers: 1 })
                    .onActionStart((event: GestureEvent) => {
                      let x = event.fingerList\[0\].localX;
                      let y = event.fingerList\[0\].localY;
                  _    // 手指按下时画布移动到当前坐标点_
                      this.context.beginPath();
                      this.context.moveTo(x, y);
                    })
                    .onActionUpdate((event: GestureEvent) => {
                      let x = event.fingerList\[0\].localX;
                      let y = event.fingerList\[0\].localY;
                   _   // 手指移动时画布用线条连接到当前坐标点_
                      this.context.lineTo(x, y);
                      this.context.stroke();
                    })
                    .onActionEnd(() => {
                     _ // 手指抬起时生成闭合路径_
                      this.context.closePath();
                    })
                )
                .expandSafeArea(\[SafeAreaType.SYSTEM\], \[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM\]);
            };
          }
        }
        
        实现效果如下：
        
        ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/33/v3/1ZVYwS3VSRqmwaKrt3eYHQ/zh-cn_image_0000002658926415.png?HW-CC-KV=V1&HW-CC-Date=20260709T134105Z&HW-CC-Expire=86400&HW-CC-Sign=0BFE9590570FF81D13AAE58A51C68402592590307FC64059A7E52972CB69A04F "点击放大")
        
*   场景二：撤销绘制的路径。
    
    在一些签名场景，如果用户绘制错误需要重新绘制，直接使用clearRect方法清空画布体验不够友好，需要仅撤销最新的绘制路径，这时可以使用数组来存储绘制过程中的路径，然后点击撤销时移除最新路径，最后重绘剩余路径，这样即可实现撤销绘制功能。示例代码参考如下：
    
    interface Point {
      x: number;
      y: number;
    }
    
    
    export class DrawingPath {
      points: Point\[\] = \[\];
    }
    
    
    @Entry
    @Component
    struct CanvasCancelDraw {
      private settings: RenderingContextSettings = new RenderingContextSettings(true);
      private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
      private paths: DrawingPath\[\] = \[\];
    
    
      build() {
        Column() {
          Canvas(this.context)
            .width('100%')
            .height('570vp')
            .borderRadius(16)
            .backgroundColor($r('sys.color.comp\_background\_focus'))
            .onReady(() => {
              this.context.lineWidth = 10;
              this.context.strokeStyle = '#0000ff';
            })
            .onTouch((event: TouchEvent) => {
              let x = event.touches\[0\].x;
              let y = event.touches\[0\].y;
              if (event.type == TouchType.Down) {
              _  // 按下时新建一条路径_
                const path = new DrawingPath();
                path.points.push({ x: x, y: y });
                this.paths.push(path);
                this.context.beginPath();
                this.context.moveTo(x, y);
              }
              if (event.type === TouchType.Move) {
               _ // 移动时把当前的坐标点放入最新的路径中_
                this.paths\[this.paths.length - 1\].points.push({ x: x, y: y });
                this.context.lineTo(x, y);
                this.context.stroke();
              }
              if (event.type === TouchType.Up) {
                this.context.closePath();
              }
            });
    
    
          Row({ space: 20 }) {
            Button('清空')
              .layoutWeight(1)
              .onClick(() => {
                this.paths = \[\];
                this.context.clearRect(0, 0, this.context.width, this.context.height);
              });
            Button('撤销')
              .layoutWeight(1)
              .onClick(() => {
                if (this.paths.length < 1) {
                  return;
                }
                this.paths.pop()!!;
                this.context.clearRect(0, 0, this.context.width, this.context.height);
              _  // 重绘保存的路径_
                this.paths.forEach((path) => {
                  if (path.points.length < 1) {
                    return;
                  }
                  this.context.beginPath();
                  this.context.moveTo(path.points\[0\].x, path.points\[0\].y);
                  for (let i = 0; i < path.points.length; i++) {
                    this.context.lineTo(path.points\[i\].x, path.points\[i\].y);
                    this.context.stroke();
                  }
                  this.context.closePath();
                });
              });
          }.width('100%')
          .justifyContent(FlexAlign.Center)
          .margin(20);
        }.padding(16);
      }
    }
    
    实现效果如下：
    
    ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/48/v3/kVQLFwoCRM2cvByE4spNeA/zh-cn_image_0000002628407212.png?HW-CC-KV=V1&HW-CC-Date=20260709T134105Z&HW-CC-Expire=86400&HW-CC-Sign=3758CDC508B1B72A32B26BEF162199977F6309289BF61AF619739EF536043C28 "点击放大")
    
*   场景三：擦除部分绘制内容。
    
    在一些绘图场景，可能需要在原有的线条基础上进行擦除而不是撤销，这时就需要globalCompositeOperation实现。Canvas是增量绘制，在原有的基础上进行擦除，可以通过设置globalCompositeOperation属性为destination-out来实现，示例代码参考如下：
    
    @Entry
    @Component
    struct CanvasEraseDraw {
      private settings: RenderingContextSettings = new RenderingContextSettings(true);
      private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
    
    
      build() {
        Column() {
          Canvas(this.context)
            .width('100%')
            .height('570vp')
            .borderRadius(16)
            .backgroundColor($r('sys.color.comp\_background\_focus'))
            .onReady(() => {
              this.context.lineWidth = 10;
              this.context.strokeStyle = '#0000ff';
            })
            .onTouch((event: TouchEvent) => {
              let x = event.touches\[0\].x;
              let y = event.touches\[0\].y;
              if (event.type == TouchType.Down) {
                this.context.beginPath();
                this.context.moveTo(x, y);
              }
              if (event.type === TouchType.Move) {
                this.context.lineTo(x, y);
                this.context.stroke();
              }
              if (event.type === TouchType.Up) {
                this.context.closePath();
              }
            });
    
    
          Row({ space: 20 }) {
            Button('清空').layoutWeight(1)
              .onClick(() => {
                this.context.clearRect(0, 0, this.context.width, this.context.height);
              });
            Button('画笔').layoutWeight(1)
              .onClick(() => {
                this.context.globalCompositeOperation = 'source-over';
                this.context.lineWidth = 10;
              });
            Button('橡皮擦').layoutWeight(1)
              .onClick(() => {
                this.context.globalCompositeOperation = 'destination-out';
                this.context.lineWidth = 30;
              });
          }
          .width('100%')
          .justifyContent(FlexAlign.Center)
          .margin(20);
        }.padding(16);
      }
    }
    
    实现效果如下：
    
    ![](https://contentcenter-vali-drcn.dbankcdn.cn/pvt_2/DeveloperAlliance_scene_100_1/0c/v3/H90Iu-kDQxSd_taClv0bAg/zh-cn_image_0000002658806469.png?HW-CC-KV=V1&HW-CC-Date=20260709T134105Z&HW-CC-Expire=86400&HW-CC-Sign=738DA7312D5BC14C1A5CEA52B9833FD936E1A7A878CD1A11AD41FEC818470577 "点击放大")
    

#### 常见FAQ

Q：涂抹过程中存在“不跟手”情况如何解决？

A：需要排查触摸的坐标点与绘制的坐标点是否一致。例如，可能存在vp与px单位不一致的问题，或者使用了globalX参数，导致坐标参考系与lineTo的参考系不同。

Q：在Canvas上实现涂抹效果是否有相关参考案例？

A：可以参考官网示例：

*   [公文审批-画板签名、文件预览下载](https://developer.huawei.com/consumer/cn/doc/architecture-guides/document_approval-0000002280673593)。
*   [图片绘制马赛克并保存](https://developer.huawei.com/consumer/cn/doc/architecture-guides/image_draw_mosaic-0000002413444896)。
*   [实现Canvas的橡皮擦效果](https://developer.huawei.com/consumer/cn/doc/harmonyos-faqs/faqs-arkui-1542)。
*   [刮刮乐抽奖效果](https://developer.huawei.com/consumer/cn/doc/architecture-guides/scratch_effect-0000002320935777)。