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}