• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright (c) 2025 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
16import { image } from '@kit.ImageKit';
17import { matrix4, window } from '@kit.ArkUI';
18import { OffsetModel } from '../model/OffsetModel';
19import { ScaleModel } from '../model/ScaleModel';
20import { runWithAnimation, simplestRotationQuarter } from '../utils/FuncUtils';
21import { windowSizeManager } from '../utils/Managers';
22import { RotateModel } from '../model/RotateModel';
23import { constrainOffsetAndAnimation, getImgSize, getMaxAllowedOffset, ImageFitType } from '../utils/Constrain';
24
25
26/**
27 * PicturePreviewImage
28 * 图片绘制组件
29 *
30 *
31 * 实现步骤:
32 * - 1. 使用matrix实现图片的缩放
33 * - 2. 使用offset实现组件的偏移
34 * - 3. 提前计算图片属性以便对组件属性进行设置
35 * - 4. Image.objectFile使用Cover以便图片能够超出其父组件显示(而不撑大父组件)
36 *
37 *
38 * @param { Color } listBGColor - 图片背景色
39 * @param { string } imageUrl   - 图片预览地址
40 * @param { Axis } listDirection - 图片预览的主轴方向
41 * @param { number } [TogglePercent] - 图片滑动多大距离需要切换图片,默认 0.2
42 * @param { number } [imageIndex] - 当前图片下标,默认 0
43 * @param { number } imageMaxLength - 最多几张图片, 默认 0
44 * @param { (offset: number, animationDuration?: number) => void  } [setListOffset] - 设置偏移尺寸
45 * @param { (index: number) => void } [setListToIndex] - 切换图片
46 *
47 */
48@Reusable
49@Component
50export struct PicturePreviewImage {
51  // 当前背景色
52  @Link listBGColor: Color;
53  // 图片显示的地址
54  @Require @Prop imageUrl: string = '';
55  // 图片滑动方向
56  @Require @Prop listDirection: Axis;
57  // 图片滑动多大距离需要切换图片
58  @Prop togglePercent: number = 0.2;
59  // 当前图片下标
60  @Prop imageIndex: number = 0;
61  // 最多几张图片
62  @Prop imageMaxLength: number = 0;
63  // 设置偏移尺寸
64  setListOffset: (offset: number, animationDuration?: number) => void =
65    (offset: number, animationDuration?: number) => {
66    };
67  // 切换图片
68  setListToIndex: (index: number) => void = (index: number) => {
69  };
70  // 图片旋转信息
71  @State imageRotateInfo: RotateModel = new RotateModel();
72  // 图片缩放信息
73  @State imageScaleInfo: ScaleModel = new ScaleModel(1.0, 1.0, 1.5, 0.3);
74  // 图片默认大小 -- 是转化后的大小
75  @State imageDefaultSize: image.Size = { width: 0, height: 0 }; // 图片默认大小,即,与屏幕大小最适配的显示大小
76  // 表示当前图片是根据宽度适配还是高度适配
77  @State imageWH: ImageFitType = ImageFitType.TYPE_DEFAULT;
78  // 本模块提供矩阵变换功能,可对图形进行平移、旋转和缩放等。
79  @State matrix: matrix4.Matrix4Transit = matrix4.identity().copy();
80  // 图片偏移信息
81  @State imageOffsetInfo: OffsetModel = new OffsetModel(0, 0);
82  // 记录偏移时控制list的偏移量
83  @State imageListOffset: number = 0;
84  // 图片原始宽高比
85  private imageWHRatio: number = 0;
86  // 保存手指移动位置 -- 减少重复触发计算
87  private eventOffsetX: number = 0;
88  private eventOffsetY: number = 0;
89  // 图片恢复的动画时长
90  private restImageAnimation: number = 300;
91  /**
92   * 当前是否移动交叉轴
93   * - 正常拖动可以移动
94   * - 当拖动时候展示出了下一张 且 展示具体 大于 isMoveMaxOffset 时固定 交叉轴,等到下次释放后再次移动
95   */
96  private isMoveCrossAxis: boolean = true;
97  /**
98   * 最大展示下一张图片的距离
99   * - 为了控制交叉轴的移动
100   * - 添加这个数值是为了防止在移动 且 图片抵达边缘时 滑动展示下一张图片会立即固定交叉轴无法移动
101   * - - 有时候移动虽然抵达边缘后会不小心移动到一点点数据但是实际行为不是为了切换照片,但此时交叉轴固定 且 视角中看不到下一个图片被误以为卡住
102   * - - 添加 30 则为了让抵达边缘后 向next 拓展距离让用户感知到已经在切换图片行为了然后固定交叉轴
103   * - 30 为可看到 next 图片,良好的距离
104   */
105  private moveMaxOffset: 30 = 30;
106
107  /**
108   * 根据图片宽高比及窗口大小计算图片的默认宽高,即,图片最适配屏幕的大小
109   * @param imageWHRatio:图片原始宽高比
110   * @param size:窗口大小{with:number,height:number}
111   * @returns image.Size
112   */
113  calcImageDefaultSize(imageWHRatio: number, windowSize: window.Size): image.Size {
114    let width: number = 0;
115    let height: number = 0;
116    if (imageWHRatio > windowSize.width / windowSize.height) {
117      // 图片宽高比大于屏幕宽高比,图片默认以屏幕宽度进行显示
118      width = windowSize.width;
119      height = windowSize.width / imageWHRatio;
120    } else {
121      height = windowSize.height;
122      width = windowSize.height * imageWHRatio;
123    }
124    return { width: width, height: height };
125  }
126
127  /**
128   * TODO:知识点:根据图片大小(宽高<=屏幕宽高)和屏幕大小计算图片放大适配屏幕进行显示的缩放倍率
129   * @param imageSize:图片当前大小
130   * @param windowSize:窗口大小
131   * @returns:缩放倍率
132   */
133  calcFitScaleRatio(imageSize: image.Size, windowSize: window.Size): number {
134    let ratio: number = 1.0;
135    if (windowSize.width > imageSize.width) {
136      ratio = windowSize.width / imageSize.width;
137    } else {
138      ratio = windowSize.height / imageSize.height;
139    }
140    return ratio;
141  }
142
143  /**
144   * 设置当前图片的相关信息:uri、whRatio、pixelMap、fitWH、defaultSize、maxScaleValue
145   * TODO:知识点:提前获取图片的信息,以进行Image组件的尺寸设置及后续的相关计算
146   */
147  initCurrentImageInfo(event: ImageLoadResult): void {
148    let imageW = event.width;
149    let imageH = event.height;
150    let windowSize = windowSizeManager.get();
151    // 图片宽高比
152    this.imageWHRatio = imageW / imageH;
153    // 图片默认大小
154    this.imageDefaultSize = this.calcImageDefaultSize(this.imageWHRatio, windowSize);
155    // 图片宽度 等于 视口宽度 则图片使用宽度适配 否则 使用 高度适配
156    if (this.imageDefaultSize.width === windowSize.width) {
157      this.imageWH = ImageFitType.TYPE_WIDTH;
158    } else {
159      this.imageWH = ImageFitType.TYPE_HEIGHT;
160    }
161    /**
162     * 1.5 的基本倍数上添加 撑满全屏需要多少倍数
163     * 1.5 是初始化时候给的值
164     *      在1.5上面加是为了让图片可以放的更大
165     */
166    this.imageScaleInfo.maxScaleValue += this.imageWH === ImageFitType.TYPE_WIDTH ?
167      (windowSize.height / this.imageDefaultSize.height) :
168      (windowSize.width / this.imageDefaultSize.width);
169  }
170
171  /**
172   * 在图片消失时,将当前图片的信息设置为默认值
173   */
174  resetCurrentImageInfo(): void {
175    animateTo({
176      duration: this.restImageAnimation
177    }, () => {
178      this.imageScaleInfo.reset();
179      this.imageOffsetInfo.reset();
180      this.imageRotateInfo.reset();
181      this.matrix = matrix4.identity().copy();
182    })
183  }
184
185  /**
186   * TODO:需求:在偏移时评估是否到达边界,以便进行位移限制与图片的切换
187   */
188  evaluateBound(): void {
189    const xBol = constrainOffsetAndAnimation({
190      dimensionWH: ImageFitType.TYPE_WIDTH,
191      imageDefaultSize: this.imageDefaultSize,
192      imageOffsetInfo: this.imageOffsetInfo,
193      scaleValue: this.imageScaleInfo.scaleValue,
194      rotate: this.imageRotateInfo.lastRotate,
195      togglePercent: this.togglePercent,
196      imageListOffset: this.imageListOffset
197    });
198
199    const yBol = constrainOffsetAndAnimation({
200      dimensionWH: ImageFitType.TYPE_HEIGHT,
201      imageDefaultSize: this.imageDefaultSize,
202      imageOffsetInfo: this.imageOffsetInfo,
203      scaleValue: this.imageScaleInfo.scaleValue,
204      rotate: this.imageRotateInfo.lastRotate,
205      togglePercent: this.togglePercent,
206      imageListOffset: this.imageListOffset
207    });
208    if (this.listDirection === Axis.Horizontal) {
209      if (xBol[0] || xBol[1]) {
210        if (xBol[0]) {
211          this.setListToIndex(this.imageIndex - 1);
212          if (this.imageIndex !== 0) {
213            this.resetCurrentImageInfo();
214          }
215        }
216        if (xBol[1]) {
217          this.setListToIndex(this.imageIndex + 1);
218          if (this.imageIndex < this.imageMaxLength - 1) {
219            this.resetCurrentImageInfo();
220          }
221        }
222      } else {
223        this.setListToIndex(this.imageIndex);
224      }
225    } else if (this.listDirection === Axis.Vertical) {
226      if (yBol[0] || yBol[1]) {
227        if (yBol[0]) {
228          this.setListToIndex(this.imageIndex - 1);
229          if (this.imageIndex !== 0) {
230            this.resetCurrentImageInfo();
231          }
232        }
233        if (yBol[1]) {
234          this.setListToIndex(this.imageIndex + 1);
235          if (this.imageIndex < this.imageMaxLength - 1) {
236            this.resetCurrentImageInfo();
237          }
238        }
239      } else {
240        this.setListToIndex(this.imageIndex);
241      }
242    }
243    this.imageListOffset = 0;
244    this.isMoveCrossAxis = true;
245  }
246
247  // 设置交叉轴位置
248  setCrossAxis(event: GestureEvent) {
249    // list当前没有在移动 &&  交叉轴时候如果没有放大也不移动
250    let isScale: boolean = this.imageScaleInfo.scaleValue !== this.imageScaleInfo.defaultScaleValue;
251    let listOffset: number = Math.abs(this.imageListOffset);
252    if (listOffset > this.moveMaxOffset) {
253      this.isMoveCrossAxis = false;
254    }
255    if (this.isMoveCrossAxis && isScale) {
256      // 获取交叉轴方向
257      let direction: 'X' | 'Y' = this.listDirection === Axis.Horizontal ? 'Y' : 'X';
258      // 获取交叉轴中对应的是 width 还是 height
259      let imageWH = this.listDirection === Axis.Horizontal ? ImageFitType.TYPE_HEIGHT : ImageFitType.TYPE_WIDTH;
260      // 获取手指在主轴移动偏移量
261      let offset = event[`offset${direction}`];
262      // 获取图片最后一次在主轴移动的数据
263      let lastOffset = imageWH === ImageFitType.TYPE_WIDTH ? this.imageOffsetInfo.lastX : this.imageOffsetInfo.lastY;
264      // 计算当前移动后偏移量结果
265      let calculatedOffset = lastOffset + offset;
266      // 设置交叉轴数据
267      this.setCurrentOffsetXY(imageWH, calculatedOffset);
268    }
269  }
270
271  // 设置主轴位置
272  setPrincipalAxis(event: GestureEvent) {
273    // 获取主轴方向
274    let direction: 'X' | 'Y' = this.listDirection === Axis.Horizontal ? 'X' : 'Y';
275    // 获取主轴中对应的是 width 还是 height
276    let imageWH: ImageFitType =
277      this.listDirection === Axis.Horizontal ? ImageFitType.TYPE_WIDTH : ImageFitType.TYPE_HEIGHT;
278    // 获取手指在主轴移动偏移量
279    let offset: number = event[`offset${direction}`];
280    // 获取图片最后一次在主轴移动的数据
281    let lastOffset: number =
282      imageWH === ImageFitType.TYPE_WIDTH ? this.imageOffsetInfo.lastX : this.imageOffsetInfo.lastY;
283    // 获取主轴上图片的尺寸
284    const IMG_SIZE: number = getImgSize(this.imageDefaultSize, this.imageRotateInfo.lastRotate, imageWH);
285    const WIN_SIZE: window.Size = windowSizeManager.get();
286    // 获取窗口对应轴的尺寸
287    const WIN_AXIS_SIZE: number = WIN_SIZE[imageWH];
288    // 当前最大移动距离
289    let maxAllowedOffset: number = getMaxAllowedOffset(WIN_AXIS_SIZE, IMG_SIZE, this.imageScaleInfo.scaleValue);
290    // 计算当前移动后偏移量结果
291    let calculatedOffset: number = lastOffset + offset;
292    if (offset < 0) {
293      // 左滑
294      if ((this.imageIndex >= this.imageMaxLength - 1) || (calculatedOffset >= -maxAllowedOffset)) {
295        // 当是最后一个元素 或者 当前移动没有抵达边缘时候触发
296        this.setCurrentOffsetXY(imageWH, calculatedOffset);
297      }
298    } else if (offset > 0) {
299      // 右滑
300      if ((this.imageIndex === 0) || (calculatedOffset <= maxAllowedOffset)) {
301        // 当是第一个元素 或者 当前移动没有抵达边缘时候触发
302        this.setCurrentOffsetXY(imageWH, calculatedOffset);
303      }
304    }
305
306
307    if ((calculatedOffset > maxAllowedOffset) && (this.imageIndex !== 0)) {
308      // 右滑 -- 当前滑动超过最大值时 并且 不是第一个元素去设置list偏移量显“下一张”图片
309      let listOffset: number = calculatedOffset - maxAllowedOffset;
310      this.setListOffset(-listOffset)
311      this.imageListOffset = listOffset;
312    } else if ((calculatedOffset < -maxAllowedOffset) && (this.imageIndex < this.imageMaxLength - 1)) {
313      // 左滑 -- 当前滑动超过最大值时 并且 不是最后一个元素去设置list偏移量显“下一张”图片
314      let listOffset = calculatedOffset + maxAllowedOffset;
315      this.setListOffset(Math.abs(listOffset));
316      this.imageListOffset = listOffset;
317    }
318
319  }
320
321  // 设置对应轴方向的数据
322  setCurrentOffsetXY(direction: ImageFitType.TYPE_WIDTH | ImageFitType.TYPE_HEIGHT, offset: number) {
323    if (direction === ImageFitType.TYPE_WIDTH) {
324      this.imageOffsetInfo.currentX = offset;
325    } else {
326      this.imageOffsetInfo.currentY = offset;
327    }
328  }
329
330  build() {
331    Stack() {
332      Image(this.imageUrl)// TODO:知识点:宽高只根据其尺寸设置一个,通过保持宽高比来设置另一个属性
333        .id('scale_image')
334        .width(this.imageWH === ImageFitType.TYPE_WIDTH ? $r('app.string.imageviewer_image_default_width') : undefined)
335        .height(this.imageWH === ImageFitType.TYPE_HEIGHT ? $r('app.string.imageviewer_image_default_height') :
336          undefined)
337        .aspectRatio(this.imageWHRatio)
338        .objectFit(ImageFit.Cover)// TODO:知识点:保持宽高比进行缩放,可以超出父组件,以便实现多图切换的增强功能
339        .autoResize(false)
340        .transform(this.matrix)// TODO:知识点:通过matrix控制图片的缩放
341        .defaultFocus(true)
342        .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
343        .offset({
344          // TODO:知识点:通过offset控制图片的偏移
345          x: this.imageOffsetInfo.currentX,
346          y: this.imageOffsetInfo.currentY
347        })
348        .onComplete((event: ImageLoadResult) => {
349          if (event) {
350            this.initCurrentImageInfo(event);
351          }
352        })
353    }
354    .alignContent(Alignment.Center)
355    .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
356    .width($r('app.string.imageviewer_image_item_stack_width'))
357    .height($r('app.string.imageviewer_image_item_stack_height'))
358    .gesture(
359      GestureGroup(
360        GestureMode.Parallel,
361        // 双击切换图片大小
362        TapGesture({ count: 2 })
363          .onAction(() => {
364            let fn: Function;
365            // 当前大小倍数 大于 默认的倍数,则是放大状态需要缩小
366            if (this.imageScaleInfo.scaleValue > this.imageScaleInfo.defaultScaleValue) {
367              fn = () => {
368                // 恢复默认大小
369                this.imageScaleInfo.reset();
370                // 重置偏移量
371                this.imageOffsetInfo.reset();
372                // 设置一个新的矩阵
373                this.matrix = matrix4.identity().copy().rotate({
374                  z: 1,
375                  angle: this.imageRotateInfo.lastRotate
376                });
377              }
378            } else {
379              fn = () => {
380                // 这里是正常状态 -- 需要放大
381                // 获取放大倍数
382                const ratio: number = this.calcFitScaleRatio(this.imageDefaultSize, windowSizeManager.get());
383                // 设置当前放大倍数
384                this.imageScaleInfo.scaleValue = ratio;
385                // 重置偏移量
386                this.imageOffsetInfo.reset();
387                // 设置矩阵元素
388                this.matrix = matrix4.identity().scale({
389                  x: ratio,
390                  y: ratio,
391                }).rotate({
392                  z: 1,
393                  angle: this.imageRotateInfo.lastRotate
394                }).copy();
395                // 设置最后放大倍数设置为当前的倍数
396                this.imageScaleInfo.stash();
397              }
398            }
399            runWithAnimation(fn);
400          }),
401        // 拖动图片
402        PanGesture({ fingers: 1 })
403          .onActionUpdate((event: GestureEvent) => {
404            if (this.imageWH != ImageFitType.TYPE_DEFAULT) {
405              if (this.eventOffsetX != event.offsetX || event.offsetY != this.eventOffsetY) {
406                this.eventOffsetX = event.offsetX;
407                this.eventOffsetY = event.offsetY;
408                this.setCrossAxis(event);
409                this.setPrincipalAxis(event);
410              }
411            }
412          })
413          .onActionEnd((event: GestureEvent) => {
414            this.imageOffsetInfo.stash();
415            this.evaluateBound();
416          }),
417        // 两根手指操作
418        // 双指旋转图片
419        RotationGesture({ angle: this.imageRotateInfo.startAngle })
420          .onActionUpdate((event: GestureEvent) => {
421            let angle: number = this.imageRotateInfo.lastRotate + event.angle;
422            if (event.angle > 0) {
423              angle -= this.imageRotateInfo.startAngle;
424            } else {
425              angle += this.imageRotateInfo.startAngle;
426            }
427            this.matrix = matrix4.identity()
428              .scale({
429                x: this.imageScaleInfo.scaleValue,
430                y: this.imageScaleInfo.scaleValue
431              })
432              .rotate({
433                x: 0,
434                y: 0,
435                z: 1,
436                angle: angle,
437              }).copy();
438            this.imageRotateInfo.currentRotate = angle;
439          })
440          .onActionEnd((event: GestureEvent) => {
441            let rotate = simplestRotationQuarter(this.imageRotateInfo.currentRotate);
442            runWithAnimation(() => {
443              this.imageRotateInfo.currentRotate = rotate;
444              this.matrix = matrix4.identity()
445                .rotate({
446                  x: 0,
447                  y: 0,
448                  z: 1,
449                  angle: this.imageRotateInfo.currentRotate,
450                }).copy();
451              this.imageRotateInfo.stash();
452              this.imageScaleInfo.reset();
453              this.imageOffsetInfo.reset();
454            })
455          }),
456        // TODO:知识点:双指捏合缩放图片
457        PinchGesture({ fingers: 2, distance: 1 })
458          .onActionUpdate((event: GestureEvent) => {
459            let scale: number = this.imageScaleInfo.lastValue * event.scale;
460            // TODO:知识点:缩放时不允许大于最大缩放因子+额外缩放因子,不允许小于默认大小-额外缩放因子,额外缩放因子用于提升用户体验4
461            if (scale > this.imageScaleInfo.maxScaleValue *
462              (1 + this.imageScaleInfo.extraScaleValue)
463            ) {
464              scale = this.imageScaleInfo.maxScaleValue *
465                (1 + this.imageScaleInfo.extraScaleValue);
466            }
467            if (scale < this.imageScaleInfo.defaultScaleValue *
468              (1 - this.imageScaleInfo.extraScaleValue)) {
469              scale = this.imageScaleInfo.defaultScaleValue *
470                (1 - this.imageScaleInfo.extraScaleValue);
471            }
472            // 当前最终的缩放比例 * 当前手指缩放比例 = 当前图片的缩放比例
473            this.imageScaleInfo.scaleValue = scale;
474            // TODO:知识点:matrix默认缩放中心为组件中心
475            this.matrix = matrix4.identity().scale({
476              x: this.imageScaleInfo.scaleValue,
477              y: this.imageScaleInfo.scaleValue,
478            }).rotate({
479              x: 0,
480              y: 0,
481              z: 1,
482              angle: this.imageRotateInfo.currentRotate,
483            }).copy();
484
485          })
486          .onActionEnd((event: GestureEvent) => {
487            // TODO:知识点:当小于默认大小时,恢复为默认大小4
488            if (this.imageScaleInfo.scaleValue < this.imageScaleInfo.defaultScaleValue) {
489              runWithAnimation(() => {
490                this.imageScaleInfo.reset();
491                this.imageOffsetInfo.reset();
492                this.matrix = matrix4.identity().rotate({
493                  x: 0,
494                  y: 0,
495                  z: 1,
496                  angle: this.imageRotateInfo.currentRotate,
497                }).copy();
498              })
499            }
500            // TODO:知识点:当大于最大缩放因子时,恢复到最大
501            if (this.imageScaleInfo.scaleValue > this.imageScaleInfo.maxScaleValue) {
502              runWithAnimation(() => {
503                this.imageScaleInfo.scaleValue = this.imageScaleInfo.maxScaleValue;
504                this.matrix = matrix4.identity()
505                  .scale({
506                    x: this.imageScaleInfo.maxScaleValue,
507                    y: this.imageScaleInfo.maxScaleValue
508                  }).rotate({
509                    x: 0,
510                    y: 0,
511                    z: 1,
512                    angle: this.imageRotateInfo.currentRotate,
513                  });
514              })
515            }
516            this.imageScaleInfo.stash();
517          })
518      )
519    )
520  }
521}