1/* 2 * Copyright (c) 2024 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16/** 17 * 实现步骤: 18 * 1.创建MyRenderNode类来负责绘制路径,并定义其属性如路径对象、颜色混合模式和线宽以便动态修改。 19 * 2.创建MyImageRenderNode类用于管理和记录画布上的图案变化,节点渲染时将属性pixelMapHistory栈顶的pixelMap绘制到画布上。 20 * 3.定义MyNodeController类管理NodeContainer上节点的创建和删除。 21 * 4.创建自定义节点容器组件NodeContainer,接收MyNodeController的实例,并通过设置blendMode属性创建离屏画布。 22 * 5.设置NodeContainer组件的宽高为图片加载完成后实际内容区域的宽高,并通过相对容器布局的alignRules使NodeContainer与图片内容区域重叠,控制 23 * 绘制区域。 24 * 6.在NodeContainer组件的onAppear()生命周期中初始化创建和挂载一个MyImageRenderNode节点currentImageNode,作为绘图的基础层。 25 * 7.创建状态变量isClear用于区分当前处于涂鸦还是橡皮擦模式,创建状态变量currentLineStrokeWidth用于设置当前绘制节点中画笔的线宽。 26 * 8.在NodeContainer组件的onTouch回调函数中,处理手指按下、移动和抬起事件,以便在屏幕上绘制或擦除路径。 27 * 9.手指按下时,如果是初次绘制,创建一个新的MyRenderNode节点存储到变量currentNodeDraw中,并将其挂载到根节点上,否则在currentNodeDraw中重 28 * 新添加路径,最后根据isClear的值修改节点中的blendMode,控制画笔涂鸦和擦除。 29 * 10.手指移动时,更新currentNodeDraw中的路径对象,并触发节点的重新渲染,绘制或擦除对应的移动轨迹。 30 * 11.手指抬起时,通过组件截图功能获取当前NodeContainer上绘制结果的pixelMap,将其存入currentImageNode节点的历史记录栈pixelMapHistory中, 31 * 并重新渲染currentImageNode节点。然后重置currentNodeDraw节点中的路径对象,并刷新currentNodeDraw。 32 * 12.从历史记录栈pixelMapHistory中移除最近一次绘制的pixelMap,刷新currentImageNode节点实现撤销功能,移除的pixelMap放入缓存栈cacheStack 33 * 中以备恢复时使用。 34 * 13.从缓存栈cacheStack中取出栈顶的pixelMap,重新放入历史记录栈pixelMapHistory中,刷新currentImageNode节点恢复上次撤销之前的状态。 35 */ 36 37import { componentSnapshot } from '@kit.ArkUI'; 38import { Constants } from '../constants/Contants'; 39import { drawing } from '@kit.ArkGraphics2D'; 40import { image } from '@kit.ImageKit'; 41import { MyImageRenderNode, MyNodeController, MyRenderNode } from '../model/RenderNodeModel'; 42 43@Component 44export struct EraserMainPage { 45 @State isClear: boolean = false; // 标记是否选中橡皮擦 46 @State undoEnabled: boolean = false; // 标记是否可以撤销 47 @State redoEnabled: boolean = false; // 标记是否可以恢复上次撤销 48 @State currentLineStrokeWidth: number = Constants.INIT_LINE_STROKE_WIDTH; // 当前画笔线宽,初始值为40 49 @State nodeContainerWidth: number = 0; // 绘制区域 NodeContainer 宽度 50 @State nodeContainerHeight: number = 0; // 绘制区域 NodeContainer 高度 51 private currentNodeDraw: MyRenderNode | null = null; // 当前正在绘制的节点,涂鸦和擦除都使用该节点进行绘制 52 private currentImageNode: MyImageRenderNode | null = null; // 用于管理和绘制之前所有绘制结果的节点 53 private myNodeController: MyNodeController = new MyNodeController(); // 初始化节点控制器 54 private lineStrokeWidths: number[] = [20, 30, 40, 50, 60]; // 画笔可选择的线宽列表 55 56 // 顶部撤销、恢复按钮模块 57 @Builder 58 topButtonLine() { 59 Row() { 60 Image($r('app.media.eraser_undo')) 61 .fillColor(this.undoEnabled ? $r('app.color.eraser_top_button_enabled_color') : 62 $r('app.color.eraser_top_button_disabled_color')) 63 .width($r('app.integer.eraser_top_button_size')) 64 .height($r('app.integer.eraser_top_button_size')) 65 .enabled(this.undoEnabled) 66 .onClick(() => { 67 this.undo(); 68 }) 69 Image($r('app.media.eraser_redo')) 70 .fillColor(this.redoEnabled ? $r('app.color.eraser_top_button_enabled_color') : 71 $r('app.color.eraser_top_button_disabled_color')) 72 .width($r('app.integer.eraser_top_button_size')) 73 .height($r('app.integer.eraser_top_button_size')) 74 .enabled(this.redoEnabled) 75 .onClick(() => { 76 this.redo(); 77 }) 78 } 79 .width($r('app.string.eraser_full_size')) 80 .height($r('app.integer.eraser_top_button_line_height')) 81 .padding($r('app.integer.eraser_padding')) 82 .backgroundColor($r('app.color.eraser_top_and_bottom_line_background_color')) 83 .alignRules({ 84 top: { anchor: Constants.CONTAINER_ID, align: VerticalAlign.Top }, 85 left: { anchor: Constants.CONTAINER_ID, align: HorizontalAlign.Start } 86 }) 87 .alignItems(VerticalAlign.Center) 88 .justifyContent(FlexAlign.SpaceBetween) 89 .id(Constants.TOP_BUTTON_LINE_ID) 90 } 91 92 // 底部画笔编辑模块,用于切换涂鸦和橡皮擦选中状态,设置线宽 93 @Builder 94 bottomPenShape() { 95 Row() { 96 // 涂鸦按钮 97 Column() { 98 Image($r('app.media.eraser_screenshot_penshape')) 99 .fillColor(this.isClear ? $r('app.color.eraser_unselected_color') : $r('app.color.eraser_selected_color')) 100 .width($r('app.integer.eraser_pen_shape_icon_size')) 101 .height($r('app.integer.eraser_pen_shape_icon_size')) 102 Text($r('app.string.eraser_pen_shape_text')) 103 .fontSize($r('app.integer.eraser_font_size')) 104 .fontColor($r('app.color.eraser_selected_color')) 105 .margin({ top: $r('app.integer.eraser_pen_shape_text_margin_top') }) 106 .fontColor(this.isClear ? $r('app.color.eraser_unselected_color') : $r('app.color.eraser_selected_color')) 107 } 108 .margin({ right: $r('app.integer.eraser_bottom_pen_shape_margin_right') }) 109 .onClick(() => { 110 this.isClear = false; 111 }) 112 113 // 橡皮擦按钮 114 Column() { 115 Image($r('app.media.eraser_screenshot_eraser')) 116 .fillColor(this.isClear ? $r('app.color.eraser_selected_color') : $r('app.color.eraser_unselected_color')) 117 .width($r('app.integer.eraser_pen_shape_icon_size')) 118 .height($r('app.integer.eraser_pen_shape_icon_size')) 119 Text($r('app.string.eraser_eraser_text')) 120 .fontSize($r('app.integer.eraser_font_size')) 121 .fontColor($r('app.color.eraser_selected_color')) 122 .margin({ top: $r('app.integer.eraser_pen_shape_text_margin_top') }) 123 .fontColor(this.isClear ? $r('app.color.eraser_selected_color') : $r('app.color.eraser_unselected_color')) 124 } 125 .onClick(() => { 126 this.isClear = true; 127 }) 128 129 Divider() 130 .vertical(true) 131 .strokeWidth(Constants.DIVIDER_STROKE_WIDTH) 132 .color($r('app.color.eraser_divider_color')) 133 .margin({ left: $r('app.integer.eraser_divider_margin'), right: $r('app.integer.eraser_divider_margin') }) 134 // 可选的线宽列表 135 Row() { 136 // TODO:性能知识点:此处列表项确定且数量较少,使用了ForEach,在列表项多的情况下,推荐使用LazyForeEach 137 ForEach(this.lineStrokeWidths, (strokeWidth: number) => { 138 Circle({ width: px2vp(strokeWidth), height: px2vp(strokeWidth) }) 139 .fill(strokeWidth === this.currentLineStrokeWidth ? $r('app.color.eraser_selected_color') : 140 $r('app.color.eraser_unselected_color'))// 为了避免线宽较小时无法点击,扩大触摸热区 141 .responseRegion(this.getResponRegion(px2vp(strokeWidth), Constants.THRESHOLD)) 142 .onClick(() => { 143 // 点击切换选中的线宽 144 this.currentLineStrokeWidth = strokeWidth; 145 }) 146 }) 147 } 148 .layoutWeight(Constants.LAYOUT_WEIGHT) 149 .justifyContent(FlexAlign.SpaceAround) 150 } 151 .width($r('app.string.eraser_full_size')) 152 .height($r('app.integer.eraser_bottom_line_height')) 153 .padding($r('app.integer.eraser_padding')) 154 .backgroundColor($r('app.color.eraser_top_and_bottom_line_background_color')) 155 .alignRules({ 156 bottom: { anchor: Constants.CONTAINER_ID, align: VerticalAlign.Bottom }, 157 left: { anchor: Constants.CONTAINER_ID, align: HorizontalAlign.Start } 158 }) 159 .alignItems(VerticalAlign.Center) 160 .id(Constants.BOTTOM_PEN_SHAPE_ID) 161 } 162 163 // 绘制区域 164 @Builder 165 drawingArea() { 166 Image($r('app.media.eraser_picture')) 167 .width($r('app.string.eraser_full_size')) 168 .objectFit(ImageFit.Contain) 169 .alignRules({ 170 top: { anchor: Constants.TOP_BUTTON_LINE_ID, align: VerticalAlign.Bottom }, 171 middle: { anchor: Constants.CONTAINER_ID, align: HorizontalAlign.Center }, 172 bottom: { anchor: Constants.BOTTOM_PEN_SHAPE_ID, align: VerticalAlign.Top } 173 }) 174 .onComplete((event) => { 175 if (event !== undefined) { 176 // NodeContainer的宽高设置为图片成功加载后实际绘制的尺寸 177 this.nodeContainerWidth = px2vp(event.contentWidth); 178 this.nodeContainerHeight = px2vp(event.contentHeight); 179 } 180 }) 181 if (this.nodeContainerWidth && this.nodeContainerHeight) { 182 NodeContainer(this.myNodeController) 183 .width(this.nodeContainerWidth) 184 .height(this.nodeContainerHeight) 185 .alignRules({ 186 top: { anchor: Constants.TOP_BUTTON_LINE_ID, align: VerticalAlign.Bottom }, 187 middle: { anchor: Constants.CONTAINER_ID, align: HorizontalAlign.Center }, 188 bottom: { anchor: Constants.BOTTOM_PEN_SHAPE_ID, align: VerticalAlign.Top } 189 }) 190 /** 191 * TODO: 知识点:NodeContainer设置属性blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN)创建一个离屏画布, 192 * NodeContainer的子节点进行颜色混合时将基于该画布进行混合 193 */ 194 .blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN) 195 .id(Constants.NODE_CONTAINER_ID) 196 // TODO: 性能知识点: onTouch是系统高频回调函数,避免在函数中进行冗余或耗时操作,例如应该减少或避免在函数打印日志,会有较大的性能损耗。 197 .onTouch((event: TouchEvent) => { 198 this.onTouchEvent(event); 199 }) 200 .onAppear(() => { 201 // NodeContainer组件挂载完成后初始化一个MyImageRenderNode节点添加到根节点上 202 if (this.currentImageNode === null) { 203 // 创建一个MyImageRenderNode对象 204 const newNode = new MyImageRenderNode(); 205 // 定义newNode的大小和位置,位置从组件NodeContainer的左上角(0,0)坐标开始,大小为NodeContainer的宽高 206 newNode.frame = { 207 x: 0, 208 y: 0, 209 width: this.nodeContainerWidth, 210 height: this.nodeContainerHeight 211 }; 212 this.currentImageNode = newNode; 213 this.myNodeController.addNode(this.currentImageNode); 214 } 215 }) 216 } 217 } 218 219 build() { 220 RelativeContainer() { 221 // 顶部撤销、恢复按钮模块 222 this.topButtonLine() 223 224 // 绘制区域 225 this.drawingArea() 226 227 // 底部画笔编辑模块,用于切换涂鸦和橡皮擦选中状态,设置线宽 228 this.bottomPenShape() 229 } 230 .height($r('app.string.eraser_full_size')) 231 .width($r('app.string.eraser_full_size')) 232 .backgroundColor($r('app.color.eraser_background_color')) 233 } 234 235 /** 236 * 更新绘制结果 237 */ 238 updateDrawResult() { 239 // TODO:知识点:通过组件截图componentSnapshot获取NodeContainer上当前绘制结果的pixelMap,需要设置waitUntilRenderFinished为true尽可能获取最新的渲染结果 240 componentSnapshot.get(Constants.NODE_CONTAINER_ID, { waitUntilRenderFinished: true }) 241 .then(async (pixelMap: image.PixelMap) => { 242 if (this.currentImageNode !== null) { 243 // 获取到的pixelMap推入pixelMapHistory栈中,并且调用invalidate重新渲染currentImageNode 244 this.currentImageNode.pixelMapHistory.push(pixelMap); 245 this.currentImageNode.invalidate(); 246 // 更新绘制结果后将用于恢复的栈清空 247 this.currentImageNode.cacheStack = []; 248 // 更新撤销和恢复按钮状态 249 this.redoEnabled = false; 250 this.undoEnabled = true; 251 if (this.currentNodeDraw !== null) { 252 // 重置绘制节点的路径, 253 this.currentNodeDraw.path.reset(); 254 this.currentNodeDraw.invalidate(); 255 } 256 } 257 }) 258 } 259 260 /** 261 * touch事件触发后绘制手指移动轨迹 262 */ 263 onTouchEvent(event: TouchEvent): void { 264 // 获取手指触摸位置的坐标点 265 const positionX: number = vp2px(event.touches[0].x); 266 const positionY: number = vp2px(event.touches[0].y); 267 switch (event.type) { 268 case TouchType.Down: { 269 // 初次绘制时创建一个新的MyRenderNode对象,用于记录和绘制手指移动的路径,后续绘制时在已创建的currentNodeDraw中重新添加路径 270 let newNode: MyRenderNode; 271 if (this.currentNodeDraw !== null) { 272 this.currentNodeDraw.path.moveTo(positionX, positionY); 273 } else { 274 const newNode = new MyRenderNode(); 275 newNode.frame = { 276 x: 0, 277 y: 0, 278 width: this.nodeContainerWidth, 279 height: this.nodeContainerHeight 280 }; 281 this.currentNodeDraw = newNode; 282 this.currentNodeDraw.path.moveTo(positionX, positionY); 283 this.myNodeController.addNode(this.currentNodeDraw); 284 } 285 // TODO:知识点:给画笔设置不同的颜色混合模式,实现涂鸦和擦除效果 286 if (!this.isClear) { 287 // SRC_OVER类型,将源像素(新绘制内容)按照透明度与目标像素(下层图像)进行混合,覆盖在目标像素(下层图像)上 288 this.currentNodeDraw.blendMode = drawing.BlendMode.SRC_OVER; 289 } else { 290 // CLEAR类型,将源像素(新绘制内容)覆盖的目标像素(下层图像)清除为完全透明 291 this.currentNodeDraw.blendMode = drawing.BlendMode.CLEAR; 292 } 293 // 修改画笔线宽 294 this.currentNodeDraw.lineStrokeWidth = this.currentLineStrokeWidth; 295 break; 296 } 297 case TouchType.Move: { 298 if (this.currentNodeDraw !== null) { 299 // 手指移动,绘制移动轨迹 300 this.currentNodeDraw.path.lineTo(positionX, positionY); 301 // 节点的path更新后需要调用invalidate()方法触发重新渲染 302 this.currentNodeDraw.invalidate(); 303 } 304 break; 305 } 306 case TouchType.Up: { 307 // 没有绘制过,即pixelMapHistory长度为0时,擦除操作不会更新绘制结果 308 if (this.isClear && this.currentImageNode?.pixelMapHistory.length === 0 && this.currentNodeDraw !== null) { 309 // 重置绘制节点的路径, 310 this.currentNodeDraw.path.reset(); 311 this.currentNodeDraw.invalidate(); 312 return; 313 } 314 // 手指离开时更新绘制结果 315 this.updateDrawResult(); 316 } 317 default: { 318 break; 319 } 320 } 321 } 322 323 /** 324 * 撤销上一笔绘制 325 */ 326 undo() { 327 if (this.currentImageNode !== null) { 328 // 绘制历史记录pixelMapHistory顶部的pixelMap出栈,推入cacheStack栈中 329 const pixelMap = this.currentImageNode.pixelMapHistory.pop(); 330 if (pixelMap) { 331 this.currentImageNode.cacheStack.push(pixelMap); 332 } 333 // 节点重新渲染,将此时pixelMapHistory栈顶的pixelMap绘制到画布上 334 this.currentImageNode.invalidate(); 335 // 更新撤销和恢复按钮状态 336 this.redoEnabled = this.currentImageNode.cacheStack.length !== 0 ? true : false; 337 this.undoEnabled = this.currentImageNode.pixelMapHistory.length !== 0 ? true : false; 338 } 339 } 340 341 /** 342 * 恢复上一次撤销 343 */ 344 redo() { 345 if (this.currentImageNode !== null) { 346 // cacheStack顶部的pixelMap出栈,推入绘制历史记录pixelMapHistory栈中 347 const pixelMap = this.currentImageNode.cacheStack.pop(); 348 if (pixelMap) { 349 this.currentImageNode.pixelMapHistory.push(pixelMap); 350 } 351 // 节点重新渲染,将此时pixelMapHistory栈顶的pixelMap绘制到画布上 352 this.currentImageNode.invalidate(); 353 // 更新撤销和恢复按钮状态 354 this.redoEnabled = this.currentImageNode.cacheStack.length !== 0 ? true : false; 355 this.undoEnabled = this.currentImageNode.pixelMapHistory.length !== 0 ? true : false; 356 } 357 } 358 359 /** 360 * 获取线宽列表项的触摸热区,尺寸小于阈值时扩大触摸热区 361 */ 362 getResponRegion(size: number, threshold: number) { 363 const rectangle: Rectangle = { 364 x: size < threshold ? (size - threshold) / 2 : 0, 365 y: size < threshold ? (size - threshold) / 2 : 0, 366 width: size < threshold ? threshold : size, 367 height: size < threshold ? threshold : size 368 } 369 return rectangle; 370 } 371} 372