• Home
Name Date Size #Lines LOC

..--

AppScope/22-Oct-2025-3532

casesfeature/erasercomponent/22-Oct-2025-807754

entry/22-Oct-2025-537489

hvigor/22-Oct-2025-2322

.gitignoreD22-Oct-2025133 1212

README.mdD22-Oct-202518.2 KiB406349

build-profile.json5D22-Oct-20251.4 KiB6160

hvigorfile.tsD22-Oct-2025843 225

oh-package.json5D22-Oct-2025808 2524

ohosTest.mdD22-Oct-20252.1 KiB1210

README.md

1# 橡皮擦案例
2
3### 介绍
4
5本示例通过[@ohos.graphics.drawing](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkgraphics2d/js-apis-graphics-drawing.md)库和[blendMode颜色混合](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/arkui-ts/ts-universal-attributes-image-effect.md#blendmode11)实现了橡皮擦功能,能够根据手指移动轨迹擦除之前绘制的内容,并且可以进行图案的撤销和恢复。
6
7### 效果图预览
8
9![](./casesfeature/erasercomponent/eraser.gif)
10
11**使用说明**
12
131. 页面底部左侧展示涂鸦和橡皮擦按钮,点击可以切换选中状态和当前的绘制模式,右侧为线宽列表,点击可以修改绘制时的轨迹宽度。
142. 在图片上触摸并拖动手指,可以绘制路径,涂鸦模式时绘制橙色线条,橡皮擦模式时擦除线条。
153. 页面顶部按钮默认不可用,进行绘制操作后左侧撤销按钮高亮,点击可以撤销上一步绘制,撤销后未进行绘制时右侧恢复按钮高亮,点击可以恢复上一次撤销。
16
17### 实现思路
18
191. 使用`NodeContainer`构建绘制区域。
20
21   - 定义`NodeController`的子类`MyNodeController`,实例化后可以通过将自绘制渲染节点`RenderNode`挂载到对应节点容器`NodeContainer`上实现自定义绘制。源码参考[RenderNodeModel.ets](casesfeature/erasercomponent/src/main/ets/model/RenderNodeModel.ets)
22
23   ```ts
24   /**
25    * NodeController的子类MyNodeController
26    */
27   export class MyNodeController extends NodeController {
28     private rootNode: FrameNode | null = null; // 根节点
29     rootRenderNode: RenderNode | null = null; // 从NodeController根节点获取的RenderNode,用于添加和删除新创建的MyRenderNode实例
30
31     // MyNodeController实例绑定的NodeContainer创建时触发,创建根节点rootNode并将其挂载至NodeContainer
32     makeNode(uiContext: UIContext): FrameNode {
33       this.rootNode = new FrameNode(uiContext);
34       if (this.rootNode !== null) {
35         this.rootRenderNode = this.rootNode.getRenderNode();
36       }
37       return this.rootNode;
38     }
39
40     // 绑定的NodeContainer布局时触发,获取NodeContainer的宽高
41     aboutToResize(size: Size): void {
42       if (this.rootRenderNode !== null) {
43         // NodeContainer布局完成后设置rootRenderNode的背景透明
44         this.rootRenderNode.backgroundColor = 0X00000000;
45         // rootRenderNode的位置从组件NodeContainer的左上角(0,0)坐标开始,大小为NodeContainer的宽高
46         this.rootRenderNode.frame = {
47           x: 0,
48           y: 0,
49           width: size.width,
50           height: size.height
51         };
52       }
53     }
54
55     // 添加节点
56     addNode(node: RenderNode): void {
57       if (this.rootNode === null) {
58         return;
59       }
60       if (this.rootRenderNode !== null) {
61         this.rootRenderNode.appendChild(node);
62       }
63     }
64
65     // 清空节点
66     clearNodes(): void {
67       if (this.rootNode === null) {
68         return;
69       }
70       if (this.rootRenderNode !== null) {
71         this.rootRenderNode.clearChildren();
72       }
73     }
74   }
75   ```
76
77   - 创建自定义节点容器组件`NodeContainer`,接收`MyNodeController`的实例,组件的宽高为图片加载完成后实际内容区域的宽高,并通过相对容器布局的`alignRules`使`NodeContainer`与图片内容区域重叠,控制绘制区域。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
78
79   ```ts
80   @Builder
81   drawingArea() {
82     Image($r('app.media.palette_picture'))
83       .width($r('app.string.palette_full_size'))
84       .objectFit(ImageFit.Contain)
85       .alignRules({
86         top: { anchor: Constants.TOP_BUTTON_LINE_ID, align: VerticalAlign.Bottom },
87         middle: { anchor: Constants.CONTAINER_ID, align: HorizontalAlign.Center },
88         bottom: { anchor: Constants.BOTTOM_PEN_SHAPE_ID, align: VerticalAlign.Top }
89       })
90       .onComplete((event) => {
91         if (event !== undefined) {
92           // NodeContainer的宽高设置为图片成功加载后实际绘制的尺寸
93           this.nodeContainerWidth = px2vp(event.contentWidth);
94           this.nodeContainerHeight = px2vp(event.contentHeight);
95         }
96       })
97     if(this.nodeContainerWidth && this.nodeContainerHeight){
98      NodeContainer(this.myNodeController)
99       .width(this.nodeContainerWidth)
100       .height(this.nodeContainerHeight)
101       .alignRules({
102         top: { anchor: Constants.TOP_BUTTON_LINE_ID, align: VerticalAlign.Bottom },
103         middle: { anchor: Constants.CONTAINER_ID, align: HorizontalAlign.Center },
104         bottom: { anchor: Constants.BOTTOM_PEN_SHAPE_ID, align: VerticalAlign.Top }
105       })
106       .id(Constants.NODE_CONTAINER_ID)
107       // ...
108     }
109   }
110   ```
111
112   - `NodeContainer`设置属性`blendMode`创建一个离屏画布,`NodeContainer`的子节点进行颜色混合时将基于该画布进行混合。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
113
114   ```ts
115   .blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN)
116   ```
117
1182. 使用`MyImageRenderNode`类创建一个节点作为绘图基本层,管理整个画布的绘制历史,记录每次绘制后的画布状态(`pixelMap`)。
119
120   - 创建`MyImageRenderNode`类,定义属性`pixelMapHistory`和`cacheStack`用于管理和记录画布上的图案变化,节点渲染时将`pixelMapHistory`栈顶的`pixelMap`绘制到画布上。源码参考[RenderNodeModel.ets](casesfeature/erasercomponent/src/main/ets/model/RenderNodeModel.ets)
121
122   ```ts
123   /**
124    * MyImageRenderNode类,绘制和记录画布图案的pixelMap
125    */
126   export class MyImageRenderNode extends RenderNode {
127     pixelMapHistory: image.PixelMap[] = []; // 记录每次绘制后画布的pixelMap
128     cacheStack: image.PixelMap[] = []; // 记录撤销时从pixelMapHistory中出栈的pixelMap,恢复时使用
129
130     // RenderNode进行绘制时会调用draw方法
131     draw(context: DrawContext): void {
132       const canvas = context.canvas;
133       if (this.pixelMapHistory.length !== 0) {
134         // 使用drawImage绘制pixelMapHistory栈顶的pixelMap
135         canvas.drawImage(this.pixelMapHistory[this.pixelMapHistory.length - 1], 0, 0);
136       }
137     }
138   }
139   ```
140
141   - 在`NodeContainer`的`onAppear`生命周期中初始化创建和挂载一个`MyImageRenderNode`节点`currentImageNode`,作为绘图的基础层。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
142
143   ```ts
144   NodeContainer(this.myNodeController)
145     // ...
146     .onAppear(() => {
147       // NodeContainer组件挂载完成后初始化一个MyImageRenderNode节点添加到根节点上
148       if (this.currentImageNode === null) {
149         // 创建一个MyImageRenderNode对象
150         const newNode = new MyImageRenderNode();
151         // 定义newNode的大小和位置,位置从组件NodeContainer的左上角(0,0)坐标开始,大小为NodeContainer的宽高
152         newNode.frame = {
153           x: 0,
154           y: 0,
155           width: this.nodeContainerWidth,
156           height: this.nodeContainerHeight
157         };
158         this.currentImageNode = newNode;
159         this.myNodeController.addNode(this.currentImageNode);
160       }
161     })
162   ```
163
1643. 创建`MyRenderNode`类来负责绘制路径,并定义其属性如路径对象、颜色混合模式和线宽以便动态修改。源码参考[RenderNodeModel.ets](casesfeature/erasercomponent/src/main/ets/model/RenderNodeModel.ets)
165
166   ```ts
167   /**
168    * MyRenderNode类,初始化画笔和绘制路径
169    */
170   export class MyRenderNode extends RenderNode {
171     path: drawing.Path = new drawing.Path(); // 新建路径对象,用于绘制手指移动轨迹
172     pen: drawing.Pen = new drawing.Pen(); // 创建一个画笔Pen对象,Pen对象用于形状的边框线绘制
173     blendMode: drawing.BlendMode = drawing.BlendMode.SRC_OVER; // 画笔的颜色混合模式
174     lineStrokeWidth: number = 0; // 画笔线宽
175
176     constructor() {
177       super();
178       // 设置画笔颜色
179       const pen_color: common2D.Color = {
180         alpha: 0xFF,
181         red: 0xFA,
182         green: 0x64,
183         blue: 0x00
184       };
185       this.pen.setColor(pen_color);
186       // 设置画笔开启反走样,可以使得图形的边缘在显示时更平滑
187       this.pen.setAntiAlias(true);
188       // 开启画笔的抖动绘制效果。抖动绘制可以使得绘制出的颜色更加真实。
189       this.pen.setDither(true);
190       // 设置画笔绘制转角的样式为圆头
191       this.pen.setJoinStyle(drawing.JoinStyle.ROUND_JOIN);
192       // 设置画笔线帽的样式,即画笔在绘制线段时在线段头尾端点的样式为半圆弧
193       this.pen.setCapStyle(drawing.CapStyle.ROUND_CAP);
194     }
195
196     // RenderNode进行绘制时会调用draw方法
197     draw(context: DrawContext): void {
198       const canvas = context.canvas;
199       // 设置画笔的颜色混合模式,根据不同的混合模式实现涂鸦和擦除效果
200       this.pen.setBlendMode(this.blendMode);
201       // 设置画笔的线宽,单位px
202       this.pen.setStrokeWidth(this.lineStrokeWidth);
203       // 将Pen画笔设置到canvas中
204       canvas.attachPen(this.pen);
205       // 绘制path
206       canvas.drawPath(this.path);
207     }
208   }
209   ```
210
2114. 在`NodeContainer`组件的`onTouch`回调函数中,处理手指按下、移动和抬起事件,以便在屏幕上绘制或擦除路径。
212
213   - 手指按下时,如果是初次绘制,创建一个新的`MyRenderNode`节点`currentNodeDraw`并将其挂载到根节点上,否则在`currentNodeDraw`中重新添加路径,根据当前的选择状态(绘制或擦除)修改节点中画笔的`blendMode`,控制画笔涂鸦和擦除。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
214
215   ```ts
216   case TouchType.Down: {
217     // 初次绘制时创建一个新的MyRenderNode对象,用于记录和绘制手指移动的路径,后续绘制时在已创建的currentNodeDraw中重新添加路径
218     let newNode: MyRenderNode;
219     if (this.currentNodeDraw !== null) {
220       this.currentNodeDraw.path.moveTo(positionX, positionY);
221     } else {
222       const newNode = new MyRenderNode();
223       newNode.frame = {
224         x: 0,
225         y: 0,
226         width: this.nodeContainerWidth,
227         height: this.nodeContainerHeight
228       };
229       this.currentNodeDraw = newNode;
230       this.currentNodeDraw.path.moveTo(positionX, positionY);
231       this.myNodeController.addNode(this.currentNodeDraw);
232     }
233     // TODO:知识点:给画笔设置不同的颜色混合模式,实现涂鸦和擦除效果
234     if (!this.isClear) {
235       // SRC_OVER类型,将源像素(新绘制内容)按照透明度与目标像素(下层图像)进行混合,覆盖在目标像素(下层图像)上
236       this.currentNodeDraw.blendMode = drawing.BlendMode.SRC_OVER;
237     } else {
238       // CLEAR类型,将源像素(新绘制内容)覆盖的目标像素(下层图像)清除为完全透明
239       this.currentNodeDraw.blendMode = drawing.BlendMode.CLEAR;
240     }
241     // 修改画笔线宽
242     this.currentNodeDraw.lineStrokeWidth = this.currentLineStrokeWidth;
243     break;
244   }
245   ```
246
247   - 手指移动时,更新`currentNodeDraw`中的路径对象,并触发节点的重新渲染,绘制或擦除对应的移动轨迹。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
248
249   ```ts
250   case TouchType.Move: {
251     if (this.currentNodeDraw !== null) {
252       // 手指移动,绘制移动轨迹
253       this.currentNodeDraw.path.lineTo(positionX, positionY);
254       // 节点的path更新后需要调用invalidate()方法触发重新渲染
255       this.currentNodeDraw.invalidate();
256     }
257     break;
258   }
259   ```
260
261   - 手指抬起时,通过组件截图功能获取当前`NodeContainer`上绘制结果的`pixelMap`,将其存入`currentImageNode`节点的历史记录栈`pixelMapHistory`中,并重新渲染`currentImageNode`节点。然后重置`currentNodeDraw`节点中的路径对象,并刷新节点。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
262
263   ```ts
264   /**
265    * touch事件触发后绘制手指移动轨迹
266    */
267   onTouchEvent(event: TouchEvent): void {
268     // 获取手指触摸位置的坐标点
269     const positionX: number = vp2px(event.touches[0].x);
270     const positionY: number = vp2px(event.touches[0].y);
271     switch (event.type) {
272       // ...
273       case TouchType.Up: {
274         // 之前没有绘制过,即pixelMapHistory长度为0时,擦除操作不会更新绘制结果
275         if (this.isClear && this.currentImageNode?.pixelMapHistory.length === 0 && this.currentNodeDraw !== null) {
276           // 重置绘制节点的路径,
277           this.currentNodeDraw.path.reset();
278           this.currentNodeDraw.invalidate();
279           return;
280         }
281         // 手指离开时更新绘制结果
282         this.updateDrawResult();
283       }
284       default: {
285         break;
286       }
287     }
288   }
289
290   /**
291    * 更新绘制结果
292    */
293   updateDrawResult() {
294     // TODO:知识点:通过组件截图componentSnapshot获取NodeContainer上当前绘制结果的pixelMap,需要设置waitUntilRenderFinished为true尽可能获取最新的渲染结果
295     componentSnapshot.get(Constants.NODE_CONTAINER_ID, { waitUntilRenderFinished: true })
296       .then(async (pixelMap: image.PixelMap) => {
297         if (this.currentImageNode !== null) {
298           // 获取到的pixelMap推入pixelMapHistory栈中,并且调用invalidate重新渲染currentImageNode
299           this.currentImageNode.pixelMapHistory.push(pixelMap);
300           this.currentImageNode.invalidate();
301           // 更新绘制结果后将用于恢复的栈清空
302           this.currentImageNode.cacheStack = [];
303           // 更新撤销和恢复按钮状态
304           this.redoEnabled = false;
305           this.undoEnabled = true;
306           if (this.currentNodeDraw !== null) {
307             // 重置绘制节点的路径,
308             this.currentNodeDraw.path.reset();
309             this.currentNodeDraw.invalidate();
310           }
311         }
312       })
313   }
314   ```
315
3165. 通过操作`currentImageNode`节点的属性`pixelMapHistory`和`cacheStack`中画布状态(`pixelMap`)的出入栈实现撤销和恢复功能。
317
318   - 从历史记录栈`pixelMapHistory`中移除最近一次绘制的`pixelMap`,刷新`currentImageNode`节点实现撤销功能,移除的`pixelMap`放入缓存栈`cacheStack`中以备恢复时使用。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
319
320   ```ts
321   /**
322    * 撤销上一笔绘制
323    */
324   undo() {
325     if (this.currentImageNode !== null) {
326       // 绘制历史记录pixelMapHistory顶部的pixelMap出栈,推入cacheStack栈中
327       const pixelMap = this.currentImageNode.pixelMapHistory.pop();
328       if (pixelMap) {
329         this.currentImageNode.cacheStack.push(pixelMap);
330       }
331       // 节点重新渲染,将此时pixelMapHistory栈顶的pixelMap绘制到画布上
332       this.currentImageNode.invalidate();
333       // 更新撤销和恢复按钮状态
334       this.redoEnabled = this.currentImageNode.cacheStack.length !== 0 ? true : false;
335       this.undoEnabled = this.currentImageNode.pixelMapHistory.length !== 0 ? true : false;
336     }
337   }
338   ```
339
340   - 从缓存栈`cacheStack`中取出栈顶的`pixelMap`,重新放入历史记录栈`pixelMapHistory`中,刷新`currentImageNode`节点恢复上次撤销之前的状态。源码参考[EraserMainPage.ets](casesfeature/erasercomponent/src/main/ets/pages/EraserMainPage.ets)
341
342   ```ts
343   /**
344    * 恢复上一次撤销
345    */
346   redo() {
347     if (this.currentImageNode !== null) {
348       // cacheStack顶部的pixelMap出栈,推入绘制历史记录pixelMapHistory栈中
349       const pixelMap = this.currentImageNode.cacheStack.pop();
350       if (pixelMap) {
351         this.currentImageNode.pixelMapHistory.push(pixelMap);
352       }
353       // 节点重新渲染,将此时pixelMapHistory栈顶的pixelMap绘制到画布上
354       this.currentImageNode.invalidate();
355       // 更新撤销和恢复按钮状态
356       this.redoEnabled = this.currentImageNode.cacheStack.length !== 0 ? true : false;
357       this.undoEnabled = this.currentImageNode.pixelMapHistory.length !== 0 ? true : false;
358     }
359   }
360   ```
361
362### 高性能知识点
363
3641. onTouch是系统高频回调函数,避免在函数中进行冗余或耗时操作,例如应该减少或避免在函数打印日志,会有较大的性能损耗。
365
366### 工程结构&模块类型
367
368   ```
369   erasercomponent                               // har类型
370   |---model
371   |   |---RenderNodeModel.ets                   // 数据模型层-节点数据模型
372   |---pages
373   |   |---EraserMainPage.ets                    // 视图层-主页面
374   |---constants
375   |   |---Constants.ets                         // 常量数据
376   ```
377
378### 参考资料
379
380[@ohos.graphics.drawing (绘制模块)](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkgraphics2d/js-apis-graphics-drawing.md)
381
382[NodeController](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/js-apis-arkui-nodeController.md)
383
384[自渲染节点RenderNode](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/js-apis-arkui-renderNode.md)
385
386[RelativeContainer相对布局](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/arkui-ts/ts-container-relativecontainer.md)
387
388[blendMode](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-arkui/arkui-ts/ts-universal-attributes-image-effect.md#blendmode11)
389
390### 约束与限制
391
3921.本示例仅支持在标准系统上运行。
393
3942.本示例需要使用DevEco Studio 5.0.0 Release 才可编译运行。
395
396### 下载
397
398如需单独下载本工程,执行如下命令:
399```
400git init
401git config core.sparsecheckout true
402echo /code/BasicFeature/Graphics/Graphics2d/Eraser > .git/info/sparse-checkout
403git remote add origin https://gitee.com/openharmony/applications_app_samples.git
404git pull origin master
405```
406