• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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