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 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