1# 共享元素转场 (一镜到底) 2<!--Kit: ArkUI--> 3<!--Subsystem: ArkUI--> 4<!--Owner: @CCFFWW--> 5<!--Designer: @yangfan229--> 6<!--Tester: @lxl007--> 7<!--Adviser: @ge-yafang--> 8 9共享元素转场是一种界面切换时对相同或者相似的两个元素做的一种位置和大小匹配的过渡动画效果,也称一镜到底动效。 10 11如下例所示,在点击图片后,该图片消失,同时在另一个位置出现新的图片,二者之间内容相同,可以对它们添加一镜到底动效。左图为不添加一镜到底动效的效果,右图为添加一镜到底动效的效果,一镜到底的效果能够让二者的出现消失产生联动,使得内容切换过程显得灵动自然而不生硬。 12 13| 14---|--- 15 16一镜到底的动效有多种实现方式,在实际开发过程中,应根据具体场景选择合适的方法进行实现。 17 18以下是不同实现方式的对比: 19 20| 一镜到底实现方式 | 特点 | 适用场景 | 21| ------ | ---- | ---- | 22| 不新建容器直接变化原容器 | 不发生路由跳转,需要在一个组件中实现展开及关闭两种状态的布局,展开后组件层级不变。| 适用于转场开销小的简单场景,如点开页面无需加载大量数据及组件。 | 23| 新建容器并跨容器迁移组件 | 通过使用NodeController,将组件从一个容器迁移到另一个容器,在开始迁移时,需要根据前后两个布局的位置大小等信息对组件添加位移及缩放,确保迁移开始时组件能够对齐初始布局,避免出现视觉上的跳变现象。之后再添加动画将位移及缩放等属性复位,实现组件从初始布局到目标布局的一镜到底过渡效果。 | 适用于新建对象开销大的场景,如视频直播组件点击转为全屏等。 | 24| 使用geometryTransition共享元素转场 | 利用系统能力,转场前后两个组件调用geometryTransition接口绑定同一id,同时将转场逻辑置于animateTo动画闭包内,这样系统侧会自动为二者添加一镜到底的过渡效果。 | 系统将调整绑定的两个组件的宽高及位置至相同值,并切换二者的透明度,以实现一镜到底过渡效果。因此,为了实现流畅的动画效果,需要确保对绑定geometryTransition的节点添加宽高动画不会有跳变。此方式适用于创建新节点开销小的场景。 | 25 26## 不新建容器并直接变化原容器 27 28该方法不新建容器,通过在已有容器上增删组件触发[transition](../reference/apis-arkui/arkui-ts/ts-transition-animation-component.md),搭配组件[属性动画](./arkts-attribute-animation-apis.md)实现一镜到底效果。 29 30对于同一个容器展开,容器内兄弟组件消失或者出现的场景,可通过对同一个容器展开前后进行宽高位置变化并配置属性动画,对兄弟组件配置出现消失转场动画实现一镜到底效果。基本步骤为: 31 321. 构建需要展开的页面,并通过状态变量构建好普通状态和展开状态的界面。 33 342. 将需要展开的页面展开,通过状态变量控制兄弟组件消失或出现,并通过绑定出现消失转场实现兄弟组件转场效果。 35 36以点击卡片后显示卡片内容详情场景为例: 37 38```ts 39class PostData { 40 // 图片使用Resource资源,需用户自定义 41 avatar: Resource = $r('app.media.flower'); 42 name: string = ''; 43 message: string = ''; 44 images: Resource[] = []; 45} 46 47@Entry 48@Component 49struct Index { 50 @State isExpand: boolean = false; 51 @State @Watch('onItemClicked') selectedIndex: number = -1; 52 53 // 数组中图片均使用Resource资源,需用户自定义 54 private allPostData: PostData[] = [ 55 { avatar: $r('app.media.flower'), name: 'Alice', message: '天气晴朗', 56 images: [$r('app.media.spring'), $r('app.media.tree')] }, 57 { avatar: $r('app.media.sky'), name: 'Bob', message: '你好世界', 58 images: [$r('app.media.island')] }, 59 { avatar: $r('app.media.tree'), name: 'Carl', message: '万物生长', 60 images: [$r('app.media.flower'), $r('app.media.sky'), $r('app.media.spring')] }]; 61 62 private onItemClicked(): void { 63 if (this.selectedIndex < 0) { 64 return; 65 } 66 this.getUIContext()?.animateTo({ 67 duration: 350, 68 curve: Curve.Friction 69 }, () => { 70 this.isExpand = !this.isExpand; 71 }); 72 } 73 74 build() { 75 Column({ space: 20 }) { 76 ForEach(this.allPostData, (postData: PostData, index: number) => { 77 // 当点击了某个post后,会使其余的post消失下树 78 if (!this.isExpand || this.selectedIndex === index) { 79 Column() { 80 Post({ data: postData, selectedIndex: this.selectedIndex, index: index }) 81 } 82 .width('100%') 83 // 对出现消失的post添加透明度转场和位移转场效果 84 .transition(TransitionEffect.OPACITY 85 .combine(TransitionEffect.translate({ y: index < this.selectedIndex ? -250 : 250 })) 86 .animation({ duration: 350, curve: Curve.Friction})) 87 } 88 }, (postData: PostData, index: number) => index.toString()) 89 } 90 .size({ width: '100%', height: '100%' }) 91 .backgroundColor('#40808080') 92 } 93} 94 95@Component 96export default struct Post { 97 @Link selectedIndex: number; 98 99 @Prop data: PostData; 100 @Prop index: number; 101 102 @State itemHeight: number = 250; 103 @State isExpand: boolean = false; 104 @State expandImageSize: number = 100; 105 @State avatarSize: number = 50; 106 107 build() { 108 Column({ space: 20 }) { 109 Row({ space: 10 }) { 110 Image(this.data.avatar) 111 .size({ width: this.avatarSize, height: this.avatarSize }) 112 .borderRadius(this.avatarSize / 2) 113 .clip(true) 114 115 Text(this.data.name) 116 } 117 .justifyContent(FlexAlign.Start) 118 119 Text(this.data.message) 120 121 Row({ space: 15 }) { 122 ForEach(this.data.images, (imageResource: Resource, index: number) => { 123 Image(imageResource) 124 .size({ width: this.expandImageSize, height: this.expandImageSize }) 125 }, (imageResource: Resource, index: number) => index.toString()) 126 } 127 128 // 展开态下组件增加的内容 129 if (this.isExpand) { 130 Column() { 131 Text('评论区') 132 // 对评论区文本添加出现消失转场效果 133 .transition( TransitionEffect.OPACITY 134 .animation({ duration: 350, curve: Curve.Friction })) 135 .padding({ top: 10 }) 136 } 137 .transition(TransitionEffect.asymmetric( 138 TransitionEffect.opacity(0.99) 139 .animation({ duration: 350, curve: Curve.Friction }), 140 TransitionEffect.OPACITY.animation({ duration: 0 }) 141 )) 142 .size({ width: '100%'}) 143 } 144 } 145 .backgroundColor(Color.White) 146 .size({ width: '100%', height: this.itemHeight }) 147 .alignItems(HorizontalAlign.Start) 148 .padding({ left: 10, top: 10 }) 149 .onClick(() => { 150 this.selectedIndex = -1; 151 this.selectedIndex = this.index; 152 this.getUIContext()?.animateTo({ 153 duration: 350, 154 curve: Curve.Friction 155 }, () => { 156 // 对展开的post做宽高动画,并对头像尺寸和图片尺寸加动画 157 this.isExpand = !this.isExpand; 158 this.itemHeight = this.isExpand ? 780 : 250; 159 this.avatarSize = this.isExpand ? 75: 50; 160 this.expandImageSize = (this.isExpand && this.data.images.length > 0) 161 ? (360 - (this.data.images.length + 1) * 15) / this.data.images.length : 100; 162 }) 163 }) 164 } 165} 166``` 167 168 169 170## 新建容器并跨容器迁移组件 171 172通过[NodeContainer](../reference/apis-arkui/arkui-ts/ts-basic-components-nodecontainer.md)[自定义占位节点](arkts-user-defined-place-holder.md),利用[NodeController](../reference/apis-arkui/js-apis-arkui-nodeController.md)实现组件的跨节点迁移,配合属性动画给组件的迁移过程赋予一镜到底效果。这种一镜到底的实现方式可以结合多种转场方式使用,如导航转场([Navigation](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md))、半模态转场([bindSheet](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#bindsheet))等。 173 174### 结合Stack使用 175 176可以利用Stack内后定义组件在最上方的特性控制组件在跨节点迁移后位z序最高,以展开收起卡片的场景为例,实现步骤为: 177 178- 展开卡片时,获取节点A的位置信息,将其中的组件迁移到与节点A位置一致的节点B处,节点B的层级高于节点A。 179 180- 对节点B添加属性动画,使之展开并运动到展开后的位置,完成一镜到底的动画效果。 181 182- 收起卡片时,对节点B添加属性动画,使之收起并运动到收起时的位置,即节点A的位置,实现一镜到底的动画效果。 183 184- 在动画结束时利用回调将节点B中的组件迁移回节点A处。 185 186```ts 187// Index.ets 188import { createPostNode, getPostNode, PostNode } from "./PostNode"; 189import { componentUtils, curves, UIContext } from '@kit.ArkUI'; 190 191@Entry 192@Component 193struct Index { 194 // 新建一镜到底动画类 195 private uiContext: UIContext = this.getUIContext(); 196 @State animationProperties: AnimationProperties = new AnimationProperties(this.uiContext); 197 private listArray: Array<number> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 198 199 build() { 200 // 卡片折叠态,展开态的共同父组件 201 Stack() { 202 List({ space: 20 }) { 203 ForEach(this.listArray, (item: number) => { 204 ListItem() { 205 // 卡片折叠态 206 PostItem({ index: item, animationProperties: this.animationProperties }) 207 } 208 }) 209 } 210 .clip(false) 211 .alignListItem(ListItemAlign.Center) 212 213 if (this.animationProperties.isExpandPageShow) { 214 // 卡片展开态 215 ExpandPage({ animationProperties: this.animationProperties }) 216 } 217 } 218 .key('rootStack') 219 .enabled(this.animationProperties.isEnabled) 220 } 221} 222 223@Component 224struct PostItem { 225 @Prop index: number 226 @Link animationProperties: AnimationProperties; 227 @State nodeController: PostNode | undefined = undefined; 228 // 折叠时详细内容隐藏 229 private showDetailContent: boolean = false; 230 231 aboutToAppear(): void { 232 this.nodeController = createPostNode(this.getUIContext(), this.index.toString(), this.showDetailContent); 233 if (this.nodeController != undefined) { 234 // 设置回调,当卡片从展开态回到折叠态时触发 235 this.nodeController.setCallback(this.resetNode.bind(this)); 236 } 237 } 238 239 resetNode() { 240 this.nodeController = getPostNode(this.index.toString()); 241 } 242 243 build() { 244 Stack() { 245 NodeContainer(this.nodeController) 246 } 247 .width('100%') 248 .height(100) 249 .key(this.index.toString()) 250 .onClick(() => { 251 if (this.nodeController != undefined) { 252 // 卡片从折叠态节点下树 253 this.nodeController.onRemove(); 254 } 255 // 触发卡片从折叠到展开态的动画 256 this.animationProperties.expandAnimation(this.index); 257 }) 258 } 259} 260 261@Component 262struct ExpandPage { 263 @Link animationProperties: AnimationProperties; 264 @State nodeController: PostNode | undefined = undefined; 265 // 展开时详细内容出现 266 private showDetailContent: boolean = true; 267 268 aboutToAppear(): void { 269 // 获取对应序号的卡片组件 270 this.nodeController = getPostNode(this.animationProperties.curIndex.toString()) 271 // 更新为详细内容出现 272 this.nodeController?.update(this.animationProperties.curIndex.toString(), this.showDetailContent) 273 } 274 275 build() { 276 Stack() { 277 NodeContainer(this.nodeController) 278 } 279 .width('100%') 280 .height(this.animationProperties.changedHeight ? '100%' : 100) 281 .translate({ x: this.animationProperties.translateX, y: this.animationProperties.translateY }) 282 .position({ x: this.animationProperties.positionX, y: this.animationProperties.positionY }) 283 .onClick(() => { 284 this.getUIContext()?.animateTo({ curve: curves.springMotion(0.6, 0.9), 285 onFinish: () => { 286 if (this.nodeController != undefined) { 287 // 执行回调,折叠态节点获取卡片组件 288 this.nodeController.callCallback(); 289 // 当前展开态节点的卡片组件下树 290 this.nodeController.onRemove(); 291 } 292 // 卡片展开态节点下树 293 this.animationProperties.isExpandPageShow = false; 294 this.animationProperties.isEnabled = true; 295 } 296 }, () => { 297 // 卡片从展开态回到折叠态 298 this.animationProperties.isEnabled = false; 299 this.animationProperties.translateX = 0; 300 this.animationProperties.translateY = 0; 301 this.animationProperties.changedHeight = false; 302 // 更新为详细内容消失 303 this.nodeController?.update(this.animationProperties.curIndex.toString(), false); 304 }) 305 }) 306 } 307} 308 309class RectInfo { 310 left: number = 0; 311 top: number = 0; 312 right: number = 0; 313 bottom: number = 0; 314 width: number = 0; 315 height: number = 0; 316} 317 318// 封装的一镜到底动画类 319@Observed 320class AnimationProperties { 321 public isExpandPageShow: boolean = false; 322 // 控制组件是否响应点击事件 323 public isEnabled: boolean = true; 324 // 展开卡片的序号 325 public curIndex: number = -1; 326 public translateX: number = 0; 327 public translateY: number = 0; 328 public positionX: number = 0; 329 public positionY: number = 0; 330 public changedHeight: boolean = false; 331 private calculatedTranslateX: number = 0; 332 private calculatedTranslateY: number = 0; 333 // 设置卡片展开后相对父组件的位置 334 private expandTranslateX: number = 0; 335 private expandTranslateY: number = 0; 336 private uiContext: UIContext; 337 338 constructor(uiContext: UIContext) { 339 this.uiContext = uiContext 340 } 341 342 public expandAnimation(index: number): void { 343 // 记录展开态卡片的序号 344 if (index != undefined) { 345 this.curIndex = index; 346 } 347 // 计算折叠态卡片相对父组件的位置 348 this.calculateData(index.toString()); 349 // 展开态卡片上树 350 this.isExpandPageShow = true; 351 // 卡片展开的属性动画 352 this.uiContext?.animateTo({ curve: curves.springMotion(0.6, 0.9) 353 }, () => { 354 this.translateX = this.calculatedTranslateX; 355 this.translateY = this.calculatedTranslateY; 356 this.changedHeight = true; 357 }) 358 } 359 360 // 获取需要跨节点迁移的组件的位置,及迁移前后节点的公共父节点的位置,用以计算做动画组件的动画参数 361 public calculateData(key: string): void { 362 let clickedImageInfo = this.getRectInfoById(this.uiContext, key); 363 let rootStackInfo = this.getRectInfoById(this.uiContext, 'rootStack'); 364 this.positionX = this.uiContext.px2vp(clickedImageInfo.left - rootStackInfo.left); 365 this.positionY = this.uiContext.px2vp(clickedImageInfo.top - rootStackInfo.top); 366 this.calculatedTranslateX = this.uiContext.px2vp(rootStackInfo.left - clickedImageInfo.left) + this.expandTranslateX; 367 this.calculatedTranslateY = this.uiContext.px2vp(rootStackInfo.top - clickedImageInfo.top) + this.expandTranslateY; 368 } 369 370 // 根据组件的id获取组件的位置信息 371 private getRectInfoById(context: UIContext, id: string): RectInfo { 372 let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id); 373 374 if (!componentInfo) { 375 throw Error('object is empty'); 376 } 377 378 let rstRect: RectInfo = new RectInfo(); 379 const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2; 380 const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2; 381 rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap; 382 rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap; 383 rstRect.right = 384 componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap; 385 rstRect.bottom = 386 componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap; 387 rstRect.width = rstRect.right - rstRect.left; 388 rstRect.height = rstRect.bottom - rstRect.top; 389 390 return { 391 left: rstRect.left, 392 right: rstRect.right, 393 top: rstRect.top, 394 bottom: rstRect.bottom, 395 width: rstRect.width, 396 height: rstRect.height 397 } 398 } 399} 400``` 401 402```ts 403// PostNode.ets 404// 跨容器迁移能力 405import { UIContext } from '@ohos.arkui.UIContext'; 406import { NodeController, BuilderNode, FrameNode } from '@ohos.arkui.node'; 407import { curves } from '@kit.ArkUI'; 408 409class Data { 410 item: string | null = null 411 isExpand: boolean = false 412} 413 414@Builder 415function PostBuilder(data: Data) { 416 // 跨容器迁移组件置于@Builder内 417 Column() { 418 Row() { 419 Row() 420 .backgroundColor(Color.Pink) 421 .borderRadius(20) 422 .width(80) 423 .height(80) 424 425 Column() { 426 Text('点击展开 Item ' + data.item) 427 .fontSize(20) 428 Text('共享元素转场') 429 .fontSize(12) 430 .fontColor(0x909399) 431 } 432 .alignItems(HorizontalAlign.Start) 433 .justifyContent(FlexAlign.SpaceAround) 434 .margin({ left: 10 }) 435 .height(80) 436 } 437 .width('90%') 438 .height(100) 439 // 展开后显示细节内容 440 if (data.isExpand) { 441 Row() { 442 Text('展开态') 443 .fontSize(28) 444 .fontColor(0x909399) 445 .textAlign(TextAlign.Center) 446 .transition(TransitionEffect.OPACITY.animation({ curve: curves.springMotion(0.6, 0.9) })) 447 } 448 .width('90%') 449 .justifyContent(FlexAlign.Center) 450 } 451 } 452 .width('90%') 453 .height('100%') 454 .alignItems(HorizontalAlign.Center) 455 .borderRadius(10) 456 .margin({ top: 15 }) 457 .backgroundColor(Color.White) 458 .shadow({ 459 radius: 20, 460 color: 0x909399, 461 offsetX: 20, 462 offsetY: 10 463 }) 464} 465 466class __InternalValue__ { 467 flag:boolean =false; 468}; 469 470export class PostNode extends NodeController { 471 private node: BuilderNode<Data[]> | null = null; 472 private isRemove: __InternalValue__ = new __InternalValue__(); 473 private callback: Function | undefined = undefined 474 private data: Data | null = null 475 476 makeNode(uiContext: UIContext): FrameNode | null { 477 if(this.isRemove.flag == true){ 478 return null; 479 } 480 if (this.node != null) { 481 return this.node.getFrameNode(); 482 } 483 484 return null; 485 } 486 487 init(uiContext: UIContext, id: string, isExpand: boolean) { 488 if (this.node != null) { 489 return; 490 } 491 // 创建节点,需要uiContext 492 this.node = new BuilderNode(uiContext) 493 // 创建离线组件 494 this.data = { item: id, isExpand: isExpand } 495 this.node.build(wrapBuilder<Data[]>(PostBuilder), this.data) 496 } 497 498 update(id: string, isExpand: boolean) { 499 if (this.node !== null) { 500 // 调用update进行更新。 501 this.data = { item: id, isExpand: isExpand } 502 this.node.update(this.data); 503 } 504 } 505 506 setCallback(callback: Function | undefined) { 507 this.callback = callback 508 } 509 510 callCallback() { 511 if (this.callback != undefined) { 512 this.callback(); 513 } 514 } 515 516 onRemove(){ 517 this.isRemove.flag = true; 518 // 组件迁移出节点时触发重建 519 this.rebuild(); 520 this.isRemove.flag = false; 521 } 522} 523 524let gNodeMap: Map<string, PostNode | undefined> = new Map(); 525 526export const createPostNode = 527 (uiContext: UIContext, id: string, isExpand: boolean): PostNode | undefined => { 528 let node = new PostNode(); 529 node.init(uiContext, id, isExpand); 530 gNodeMap.set(id, node); 531 return node; 532 } 533 534export const getPostNode = (id: string): PostNode | undefined => { 535 if (!gNodeMap.has(id)) { 536 return undefined 537 } 538 return gNodeMap.get(id); 539} 540 541export const deleteNode = (id: string) => { 542 gNodeMap.delete(id) 543} 544``` 545 546 547 548### 结合Navigation使用 549 550可以利用[Navigation](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md)的自定义导航转场动画能力([customNavContentTransition](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md#customnavcontenttransition11),可参考Navigation[示例3](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md#示例3设置可交互转场动画))实现一镜到底动效。共享元素转场期间,组件由消失页面迁移至出现页面。 551 552以展开收起缩略图的场景为例,实现步骤为: 553 554- 通过customNavContentTransition配置PageOne与PageTwo的自定义导航转场动画。 555 556- 自定义的共享元素转场效果由属性动画实现,具体实现方式为抓取页面内组件相对窗口的位置信息从而正确匹配组件在PageOne与PageTwo的位置、缩放等,即动画开始和结束的属性信息。 557 558- 点击缩略图后共享元素组件从PageOne被迁移至PageTwo,随后触发由PageOne至PageTwo的自定义转场动画,即PageTwo的共享元素组件从原来的缩略图状态做动画到全屏状态。 559 560- 由全屏状态返回到缩略图时,触发由PageTwo至PageOne的自定义转场动画,即PageTwo的共享元素组件从全屏状态做动画到原PageOne的缩略图状态,转场结束后共享元素组件从PageTwo被迁移回PageOne。 561 562``` 563├──entry/src/main/ets // 代码区 564│ ├──CustomTransition 565│ │ ├──AnimationProperties.ets // 一镜到底转场动画封装 566│ │ └──CustomNavigationUtils.ets // Navigation自定义转场动画配置 567│ ├──entryability 568│ │ └──EntryAbility.ets // 程序入口类 569│ ├──NodeContainer 570│ │ └──CustomComponent.ets // 自定义占位节点 571│ ├──pages 572│ │ ├──Index.ets // 导航页面 573│ │ ├──PageOne.ets // 缩略图页面 574│ │ └──PageTwo.ets // 全屏展开页面 575│ └──utils 576│ ├──ComponentAttrUtils.ets // 组件位置获取 577│ └──WindowUtils.ets // 窗口信息 578└──entry/src/main/resources // 资源文件 579``` 580 581```ts 582// Index.ets 583import { AnimateCallback, CustomTransition } from '../CustomTransition/CustomNavigationUtils'; 584 585const TAG: string = 'Index'; 586 587@Entry 588@Component 589struct Index { 590 private pageInfos: NavPathStack = new NavPathStack(); 591 // 允许进行自定义转场的页面名称 592 private allowedCustomTransitionFromPageName: string[] = ['PageOne']; 593 private allowedCustomTransitionToPageName: string[] = ['PageTwo']; 594 595 aboutToAppear(): void { 596 this.pageInfos.pushPath({ name: 'PageOne' }); 597 } 598 599 private isCustomTransitionEnabled(fromName: string, toName: string): boolean { 600 // 点击和返回均需要进行自定义转场,因此需要分别判断 601 if ((this.allowedCustomTransitionFromPageName.includes(fromName) 602 && this.allowedCustomTransitionToPageName.includes(toName)) 603 || (this.allowedCustomTransitionFromPageName.includes(toName) 604 && this.allowedCustomTransitionToPageName.includes(fromName))) { 605 return true; 606 } 607 return false; 608 } 609 610 build() { 611 Navigation(this.pageInfos) 612 .hideNavBar(true) 613 .customNavContentTransition((from: NavContentInfo, to: NavContentInfo, operation: NavigationOperation) => { 614 if ((!from || !to) || (!from.name || !to.name)) { 615 return undefined; 616 } 617 618 // 通过from和to的name对自定义转场路由进行管控 619 if (!this.isCustomTransitionEnabled(from.name, to.name)) { 620 return undefined; 621 } 622 623 // 需要对转场页面是否注册了animation进行判断,来决定是否进行自定义转场 624 let fromParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(from.index); 625 let toParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(to.index); 626 if (!fromParam.animation || !toParam.animation) { 627 return undefined; 628 } 629 630 // 一切判断完成后,构造customAnimation给系统侧调用,执行自定义转场动画 631 let customAnimation: NavigationAnimatedTransition = { 632 onTransitionEnd: (isSuccess: boolean) => { 633 console.info(TAG, `current transition result is ${isSuccess}`); 634 }, 635 timeout: 2000, 636 transition: (transitionProxy: NavigationTransitionProxy) => { 637 console.info(TAG, 'trigger transition callback'); 638 if (fromParam.animation) { 639 fromParam.animation(operation == NavigationOperation.PUSH, true, transitionProxy); 640 } 641 if (toParam.animation) { 642 toParam.animation(operation == NavigationOperation.PUSH, false, transitionProxy); 643 } 644 } 645 }; 646 return customAnimation; 647 }) 648 } 649} 650``` 651 652```ts 653// PageOne.ets 654import { CustomTransition } from '../CustomTransition/CustomNavigationUtils'; 655import { MyNodeController, createMyNode, getMyNode } from '../NodeContainer/CustomComponent'; 656import { ComponentAttrUtils, RectInfoInPx } from '../utils/ComponentAttrUtils'; 657import { WindowUtils } from '../utils/WindowUtils'; 658 659@Builder 660export function PageOneBuilder() { 661 PageOne(); 662} 663 664@Component 665export struct PageOne { 666 private pageInfos: NavPathStack = new NavPathStack(); 667 private pageId: number = -1; 668 @State myNodeController: MyNodeController | undefined = new MyNodeController(false); 669 670 aboutToAppear(): void { 671 let node = getMyNode(); 672 if (node == undefined) { 673 // 新建自定义节点 674 createMyNode(this.getUIContext()); 675 } 676 this.myNodeController = getMyNode(); 677 } 678 679 private doFinishTransition(): void { 680 // PageTwo结束转场时将节点从PageTwo迁移回PageOne 681 this.myNodeController = getMyNode(); 682 } 683 684 private registerCustomTransition(): void { 685 // 注册自定义动画协议 686 CustomTransition.getInstance().registerNavParam(this.pageId, 687 (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => {}, 500); 688 } 689 690 private onCardClicked(): void { 691 let cardItemInfo: RectInfoInPx = 692 ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), 'card'); 693 let param: Record<string, Object> = {}; 694 param['cardItemInfo'] = cardItemInfo; 695 param['doDefaultTransition'] = (myController: MyNodeController) => { 696 this.doFinishTransition() 697 }; 698 this.pageInfos.pushPath({ name: 'PageTwo', param: param }); 699 // 自定义节点从PageOne下树 700 if (this.myNodeController != undefined) { 701 (this.myNodeController as MyNodeController).onRemove(); 702 } 703 } 704 705 build() { 706 NavDestination() { 707 Stack() { 708 Column({ space: 20 }) { 709 Row({ space: 10 }) { 710 // 图片使用Resource资源,需用户自定义 711 Image($r("app.media.avatar")) 712 .size({ width: 50, height: 50 }) 713 .borderRadius(25) 714 .clip(true) 715 716 Text('Alice') 717 } 718 .justifyContent(FlexAlign.Start) 719 720 Text('你好世界') 721 722 NodeContainer(this.myNodeController) 723 .size({ width: 320, height: 250 }) 724 .onClick(() => { 725 this.onCardClicked() 726 }) 727 } 728 .alignItems(HorizontalAlign.Start) 729 .margin(30) 730 } 731 } 732 .onReady((context: NavDestinationContext) => { 733 this.pageInfos = context.pathStack; 734 this.pageId = this.pageInfos.getAllPathName().length - 1; 735 this.registerCustomTransition(); 736 }) 737 .onDisAppear(() => { 738 CustomTransition.getInstance().unRegisterNavParam(this.pageId); 739 // 自定义节点从PageOne下树 740 if (this.myNodeController != undefined) { 741 (this.myNodeController as MyNodeController).onRemove(); 742 } 743 }) 744 } 745} 746``` 747 748```ts 749// PageTwo.ets 750import { CustomTransition } from '../CustomTransition/CustomNavigationUtils'; 751import { AnimationProperties } from '../CustomTransition/AnimationProperties'; 752import { RectInfoInPx } from '../utils/ComponentAttrUtils'; 753import { getMyNode, MyNodeController } from '../NodeContainer/CustomComponent'; 754 755@Builder 756export function PageTwoBuilder() { 757 PageTwo(); 758} 759 760@Component 761export struct PageTwo { 762 @State pageInfos: NavPathStack = new NavPathStack(); 763 @State animationProperties: AnimationProperties = new AnimationProperties(this.getUIContext()); 764 @State myNodeController: MyNodeController | undefined = new MyNodeController(false); 765 766 private pageId: number = -1; 767 768 private shouldDoDefaultTransition: boolean = false; 769 private prePageDoFinishTransition: () => void = () => {}; 770 private cardItemInfo: RectInfoInPx = new RectInfoInPx(); 771 772 @StorageProp('windowSizeChanged') @Watch('unRegisterNavParam') windowSizeChangedTime: number = 0; 773 @StorageProp('onConfigurationUpdate') @Watch('unRegisterNavParam') onConfigurationUpdateTime: number = 0; 774 775 aboutToAppear(): void { 776 // 迁移自定义节点至当前页面 777 this.myNodeController = getMyNode(); 778 } 779 780 private unRegisterNavParam(): void { 781 this.shouldDoDefaultTransition = true; 782 } 783 784 private onBackPressed(): boolean { 785 if (this.shouldDoDefaultTransition) { 786 CustomTransition.getInstance().unRegisterNavParam(this.pageId); 787 this.pageInfos.pop(); 788 this.prePageDoFinishTransition(); 789 this.shouldDoDefaultTransition = false; 790 return true; 791 } 792 this.pageInfos.pop(); 793 return true; 794 } 795 796 build() { 797 NavDestination() { 798 // Stack需要设置alignContent为TopStart,否则在高度变化过程中,截图和内容都会随高度重新布局位置 799 Stack({ alignContent: Alignment.TopStart }) { 800 Stack({ alignContent: Alignment.TopStart }) { 801 Column({space: 20}) { 802 NodeContainer(this.myNodeController) 803 if (this.animationProperties.showDetailContent) 804 Text('展开态内容') 805 .fontSize(20) 806 .transition(TransitionEffect.OPACITY) 807 .margin(30) 808 } 809 .alignItems(HorizontalAlign.Start) 810 } 811 .position({ y: this.animationProperties.positionValue }) 812 } 813 .scale({ x: this.animationProperties.scaleValue, y: this.animationProperties.scaleValue }) 814 .translate({ x: this.animationProperties.translateX, y: this.animationProperties.translateY }) 815 .width(this.animationProperties.clipWidth) 816 .height(this.animationProperties.clipHeight) 817 .borderRadius(this.animationProperties.radius) 818 // expandSafeArea使得Stack做沉浸式效果,向上扩到状态栏,向下扩到导航条 819 .expandSafeArea([SafeAreaType.SYSTEM]) 820 // 对高度进行裁切 821 .clip(true) 822 } 823 .backgroundColor(this.animationProperties.navDestinationBgColor) 824 .hideTitleBar(true) 825 .onReady((context: NavDestinationContext) => { 826 this.pageInfos = context.pathStack; 827 this.pageId = this.pageInfos.getAllPathName().length - 1; 828 let param = context.pathInfo?.param as Record<string, Object>; 829 this.prePageDoFinishTransition = param['doDefaultTransition'] as () => void; 830 this.cardItemInfo = param['cardItemInfo'] as RectInfoInPx; 831 CustomTransition.getInstance().registerNavParam(this.pageId, 832 (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => { 833 this.animationProperties.doAnimation( 834 this.cardItemInfo, isPush, isExit, transitionProxy, 0, 835 this.prePageDoFinishTransition, this.myNodeController); 836 }, 500); 837 }) 838 .onBackPressed(() => { 839 return this.onBackPressed(); 840 }) 841 .onDisAppear(() => { 842 CustomTransition.getInstance().unRegisterNavParam(this.pageId); 843 }) 844 } 845} 846``` 847 848```ts 849// CustomNavigationUtils.ets 850// 配置Navigation自定义转场动画 851export interface AnimateCallback { 852 animation: ((isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void | undefined) 853 | undefined; 854 timeout: (number | undefined) | undefined; 855} 856 857const customTransitionMap: Map<number, AnimateCallback> = new Map(); 858 859export class CustomTransition { 860 private constructor() {}; 861 862 static delegate = new CustomTransition(); 863 864 static getInstance() { 865 return CustomTransition.delegate; 866 } 867 868 // 注册页面的动画回调,name是注册页面的动画的回调 869 // animationCallback是需要执行的动画内容,timeout是转场结束的超时时间 870 registerNavParam( 871 name: number, 872 animationCallback: (operation: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void, 873 timeout: number): void { 874 if (customTransitionMap.has(name)) { 875 let param = customTransitionMap.get(name); 876 if (param != undefined) { 877 param.animation = animationCallback; 878 param.timeout = timeout; 879 return; 880 } 881 } 882 let params: AnimateCallback = { timeout: timeout, animation: animationCallback }; 883 customTransitionMap.set(name, params); 884 } 885 886 unRegisterNavParam(name: number): void { 887 customTransitionMap.delete(name); 888 } 889 890 getAnimateParam(name: number): AnimateCallback { 891 let result: AnimateCallback = { 892 animation: customTransitionMap.get(name)?.animation, 893 timeout: customTransitionMap.get(name)?.timeout, 894 }; 895 return result; 896 } 897} 898``` 899 900```ts 901// 工程配置文件module.json5中配置 {"routerMap": "$profile:route_map"} 902// route_map.json 903{ 904 "routerMap": [ 905 { 906 "name": "PageOne", 907 "pageSourceFile": "src/main/ets/pages/PageOne.ets", 908 "buildFunction": "PageOneBuilder" 909 }, 910 { 911 "name": "PageTwo", 912 "pageSourceFile": "src/main/ets/pages/PageTwo.ets", 913 "buildFunction": "PageTwoBuilder" 914 } 915 ] 916} 917``` 918 919```ts 920// AnimationProperties.ets 921// 一镜到底转场动画封装 922import { curves, UIContext } from '@kit.ArkUI'; 923import { RectInfoInPx } from '../utils/ComponentAttrUtils'; 924import { WindowUtils } from '../utils/WindowUtils'; 925import { MyNodeController } from '../NodeContainer/CustomComponent'; 926 927const TAG: string = 'AnimationProperties'; 928 929const DEVICE_BORDER_RADIUS: number = 34; 930 931// 将自定义一镜到底转场动画进行封装,其他界面也需要做自定义一镜到底转场的话,可以直接复用,减少工作量 932@Observed 933export class AnimationProperties { 934 public navDestinationBgColor: ResourceColor = Color.Transparent; 935 public translateX: number = 0; 936 public translateY: number = 0; 937 public scaleValue: number = 1; 938 public clipWidth: Dimension = 0; 939 public clipHeight: Dimension = 0; 940 public radius: number = 0; 941 public positionValue: number = 0; 942 public showDetailContent: boolean = false; 943 private uiContext: UIContext; 944 945 constructor(uiContext: UIContext) { 946 this.uiContext = uiContext 947 } 948 949 public doAnimation(cardItemInfo_px: RectInfoInPx, isPush: boolean, isExit: boolean, 950 transitionProxy: NavigationTransitionProxy, extraTranslateValue: number, prePageOnFinish: (index: MyNodeController) => void, myNodeController: MyNodeController | undefined): void { 951 // 首先计算卡片的宽高与窗口宽高的比例 952 let widthScaleRatio = cardItemInfo_px.width / WindowUtils.windowWidth_px; 953 let heightScaleRatio = cardItemInfo_px.height / WindowUtils.windowHeight_px; 954 let isUseWidthScale = widthScaleRatio > heightScaleRatio; 955 let initScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio; 956 957 let initTranslateX: number = 0; 958 let initTranslateY: number = 0; 959 let initClipWidth: Dimension = 0; 960 let initClipHeight: Dimension = 0; 961 // 使得PageTwo卡片向上扩到状态栏 962 let initPositionValue: number = -this.uiContext.px2vp(WindowUtils.topAvoidAreaHeight_px + extraTranslateValue); 963 964 if (isUseWidthScale) { 965 initTranslateX = this.uiContext.px2vp(cardItemInfo_px.left - (WindowUtils.windowWidth_px - cardItemInfo_px.width) / 2); 966 initClipWidth = '100%'; 967 initClipHeight = this.uiContext.px2vp((cardItemInfo_px.height) / initScale); 968 initTranslateY = this.uiContext.px2vp(cardItemInfo_px.top - ((this.uiContext.vp2px(initClipHeight) - this.uiContext.vp2px(initClipHeight) * initScale) / 2)); 969 } else { 970 initTranslateY = this.uiContext.px2vp(cardItemInfo_px.top - (WindowUtils.windowHeight_px - cardItemInfo_px.height) / 2); 971 initClipHeight = '100%'; 972 initClipWidth = this.uiContext.px2vp((cardItemInfo_px.width) / initScale); 973 initTranslateX = this.uiContext.px2vp(cardItemInfo_px.left - (WindowUtils.windowWidth_px / 2 - cardItemInfo_px.width / 2)); 974 } 975 976 // 转场动画开始前通过计算scale、translate、position和clip height & width,确定节点迁移前后位置一致 977 console.info(TAG, 'initScale: ' + initScale + ' initTranslateX ' + initTranslateX + 978 ' initTranslateY ' + initTranslateY + ' initClipWidth ' + initClipWidth + 979 ' initClipHeight ' + initClipHeight + ' initPositionValue ' + initPositionValue); 980 // 转场至新页面 981 if (isPush && !isExit) { 982 this.scaleValue = initScale; 983 this.translateX = initTranslateX; 984 this.clipWidth = initClipWidth; 985 this.clipHeight = initClipHeight; 986 this.translateY = initTranslateY; 987 this.positionValue = initPositionValue; 988 989 this.uiContext?.animateTo({ 990 curve: curves.interpolatingSpring(0, 1, 328, 36), 991 onFinish: () => { 992 if (transitionProxy) { 993 transitionProxy.finishTransition(); 994 } 995 } 996 }, () => { 997 this.scaleValue = 1.0; 998 this.translateX = 0; 999 this.translateY = 0; 1000 this.clipWidth = '100%'; 1001 this.clipHeight = '100%'; 1002 // 页面圆角与系统圆角一致 1003 this.radius = DEVICE_BORDER_RADIUS; 1004 this.showDetailContent = true; 1005 }) 1006 1007 this.uiContext?.animateTo({ 1008 duration: 100, 1009 curve: Curve.Sharp, 1010 }, () => { 1011 // 页面由透明逐渐变为设置背景色 1012 this.navDestinationBgColor = '#00ffffff'; 1013 }) 1014 1015 // 返回旧页面 1016 } else if (!isPush && isExit) { 1017 1018 this.uiContext?.animateTo({ 1019 duration: 350, 1020 curve: Curve.EaseInOut, 1021 onFinish: () => { 1022 if (transitionProxy) { 1023 transitionProxy.finishTransition(); 1024 } 1025 prePageOnFinish(myNodeController); 1026 // 自定义节点从PageTwo下树 1027 if (myNodeController != undefined) { 1028 (myNodeController as MyNodeController).onRemove(); 1029 } 1030 } 1031 }, () => { 1032 this.scaleValue = initScale; 1033 this.translateX = initTranslateX; 1034 this.translateY = initTranslateY; 1035 this.radius = 0; 1036 this.clipWidth = initClipWidth; 1037 this.clipHeight = initClipHeight; 1038 this.showDetailContent = false; 1039 }) 1040 1041 this.uiContext?.animateTo({ 1042 duration: 200, 1043 delay: 150, 1044 curve: Curve.Friction, 1045 }, () => { 1046 this.navDestinationBgColor = Color.Transparent; 1047 }) 1048 } 1049 } 1050} 1051``` 1052 1053```ts 1054// ComponentAttrUtils.ets 1055// 获取组件相对窗口的位置 1056import { componentUtils, UIContext } from '@kit.ArkUI'; 1057import { JSON } from '@kit.ArkTS'; 1058 1059export class ComponentAttrUtils { 1060 // 根据组件的id获取组件的位置信息 1061 public static getRectInfoById(context: UIContext, id: string): RectInfoInPx { 1062 if (!context || !id) { 1063 throw Error('object is empty'); 1064 } 1065 let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id); 1066 1067 if (!componentInfo) { 1068 throw Error('object is empty'); 1069 } 1070 1071 let rstRect: RectInfoInPx = new RectInfoInPx(); 1072 const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2; 1073 const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2; 1074 rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap; 1075 rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap; 1076 rstRect.right = 1077 componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap; 1078 rstRect.bottom = 1079 componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap; 1080 rstRect.width = rstRect.right - rstRect.left; 1081 rstRect.height = rstRect.bottom - rstRect.top; 1082 return { 1083 left: rstRect.left, 1084 right: rstRect.right, 1085 top: rstRect.top, 1086 bottom: rstRect.bottom, 1087 width: rstRect.width, 1088 height: rstRect.height 1089 } 1090 } 1091} 1092 1093export class RectInfoInPx { 1094 left: number = 0; 1095 top: number = 0; 1096 right: number = 0; 1097 bottom: number = 0; 1098 width: number = 0; 1099 height: number = 0; 1100} 1101 1102export class RectJson { 1103 $rect: Array<number> = []; 1104} 1105``` 1106 1107```ts 1108// WindowUtils.ets 1109// 窗口信息 1110import { window } from '@kit.ArkUI'; 1111 1112export class WindowUtils { 1113 public static window: window.Window; 1114 public static windowWidth_px: number; 1115 public static windowHeight_px: number; 1116 public static topAvoidAreaHeight_px: number; 1117 public static navigationIndicatorHeight_px: number; 1118} 1119``` 1120 1121```ts 1122// EntryAbility.ets 1123// 程序入口处的onWindowStageCreate增加对窗口宽高等的抓取 1124 1125import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; 1126import { hilog } from '@kit.PerformanceAnalysisKit'; 1127import { display, window } from '@kit.ArkUI'; 1128import { WindowUtils } from '../utils/WindowUtils'; 1129 1130const TAG: string = 'EntryAbility'; 1131 1132export default class EntryAbility extends UIAbility { 1133 private currentBreakPoint: string = ''; 1134 1135 onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { 1136 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate'); 1137 } 1138 1139 onDestroy(): void { 1140 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy'); 1141 } 1142 1143 onWindowStageCreate(windowStage: window.WindowStage): void { 1144 // Main window is created, set main page for this ability 1145 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); 1146 1147 // 获取窗口宽高 1148 WindowUtils.window = windowStage.getMainWindowSync(); 1149 WindowUtils.windowWidth_px = WindowUtils.window.getWindowProperties().windowRect.width; 1150 WindowUtils.windowHeight_px = WindowUtils.window.getWindowProperties().windowRect.height; 1151 1152 this.updateBreakpoint(WindowUtils.windowWidth_px); 1153 1154 // 获取上方避让区(状态栏等)高度 1155 let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); 1156 WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height; 1157 1158 // 获取导航条高度 1159 let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); 1160 WindowUtils.navigationIndicatorHeight_px = navigationArea.bottomRect.height; 1161 1162 hilog.info(0x0000, TAG, 'the width is ' + WindowUtils.windowWidth_px + ' ' + WindowUtils.windowHeight_px + ' ' + 1163 WindowUtils.topAvoidAreaHeight_px + ' ' + WindowUtils.navigationIndicatorHeight_px); 1164 1165 // 监听窗口尺寸、状态栏高度及导航条高度的变化并更新 1166 try { 1167 WindowUtils.window.on('windowSizeChange', (data) => { 1168 hilog.info(0x0000, TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height); 1169 WindowUtils.windowWidth_px = data.width; 1170 WindowUtils.windowHeight_px = data.height; 1171 this.updateBreakpoint(data.width); 1172 AppStorage.setOrCreate('windowSizeChanged', Date.now()) 1173 }) 1174 1175 WindowUtils.window.on('avoidAreaChange', (data) => { 1176 if (data.type == window.AvoidAreaType.TYPE_SYSTEM) { 1177 let topRectHeight = data.area.topRect.height; 1178 hilog.info(0x0000, TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight); 1179 WindowUtils.topAvoidAreaHeight_px = topRectHeight; 1180 } else if (data.type == window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) { 1181 let bottomRectHeight = data.area.bottomRect.height; 1182 hilog.info(0x0000, TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight); 1183 WindowUtils.navigationIndicatorHeight_px = bottomRectHeight; 1184 } 1185 }) 1186 } catch (exception) { 1187 hilog.error(0x0000, TAG, `register failed. code: ${exception.code}, message: ${exception.message}`); 1188 } 1189 1190 windowStage.loadContent('pages/Index', (err) => { 1191 if (err.code) { 1192 hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); 1193 return; 1194 } 1195 hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.'); 1196 }); 1197 } 1198 1199 updateBreakpoint(width: number) { 1200 let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160); 1201 let newBreakPoint: string = ''; 1202 if (windowWidthVp < 400) { 1203 newBreakPoint = 'xs'; 1204 } else if (windowWidthVp < 600) { 1205 newBreakPoint = 'sm'; 1206 } else if (windowWidthVp < 800) { 1207 newBreakPoint = 'md'; 1208 } else { 1209 newBreakPoint = 'lg'; 1210 } 1211 if (this.currentBreakPoint !== newBreakPoint) { 1212 this.currentBreakPoint = newBreakPoint; 1213 // 使用状态变量记录当前断点值 1214 AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint); 1215 } 1216 } 1217 1218 onWindowStageDestroy(): void { 1219 // Main window is destroyed, release UI related resources 1220 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); 1221 } 1222 1223 onForeground(): void { 1224 // Ability has brought to foreground 1225 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground'); 1226 } 1227 1228 onBackground(): void { 1229 // Ability has back to background 1230 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground'); 1231 } 1232} 1233``` 1234 1235```ts 1236// CustomComponent.ets 1237// 自定义占位节点,跨容器迁移能力 1238import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI'; 1239 1240@Builder 1241function CardBuilder() { 1242 // 图片使用Resource资源,需用户自定义 1243 Image($r("app.media.card")) 1244 .width('100%') 1245 .id('card') 1246} 1247 1248export class MyNodeController extends NodeController { 1249 private CardNode: BuilderNode<[]> | null = null; 1250 private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(CardBuilder); 1251 private needCreate: boolean = false; 1252 private isRemove: boolean = false; 1253 1254 constructor(create: boolean) { 1255 super(); 1256 this.needCreate = create; 1257 } 1258 1259 makeNode(uiContext: UIContext): FrameNode | null { 1260 if(this.isRemove == true){ 1261 return null; 1262 } 1263 if (this.needCreate && this.CardNode == null) { 1264 this.CardNode = new BuilderNode(uiContext); 1265 this.CardNode.build(this.wrapBuilder) 1266 } 1267 if (this.CardNode == null) { 1268 return null; 1269 } 1270 return this.CardNode!.getFrameNode()!; 1271 } 1272 1273 getNode(): BuilderNode<[]> | null { 1274 return this.CardNode; 1275 } 1276 1277 setNode(node: BuilderNode<[]> | null) { 1278 this.CardNode = node; 1279 this.rebuild(); 1280 } 1281 1282 onRemove() { 1283 this.isRemove = true; 1284 this.rebuild(); 1285 this.isRemove = false; 1286 } 1287 1288 init(uiContext: UIContext) { 1289 this.CardNode = new BuilderNode(uiContext); 1290 this.CardNode.build(this.wrapBuilder) 1291 } 1292} 1293 1294let myNode: MyNodeController | undefined; 1295 1296export const createMyNode = 1297 (uiContext: UIContext) => { 1298 myNode = new MyNodeController(false); 1299 myNode.init(uiContext); 1300 } 1301 1302export const getMyNode = (): MyNodeController | undefined => { 1303 return myNode; 1304} 1305``` 1306 1307 1308 1309### 结合BindSheet使用 1310 1311想实现半模态转场([bindSheet](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#bindsheet))的同时,组件从初始界面做一镜到底动画到半模态页面的效果,可以使用这样的设计思路。将[SheetOptions](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#sheetoptions)中的mode设置为SheetMode.EMBEDDED,该模式下新起的页面可以覆盖在半模态弹窗上,页面返回后该半模态依旧存在,半模态面板内容不丢失。在半模态转场的同时设置一全模态转场([bindContentCover](../reference/apis-arkui/arkui-ts/ts-universal-attributes-modal-transition.md#bindcontentcover))页面无转场出现,该页面仅有需要做共享元素转场的组件,通过属性动画,展示组件从初始界面至半模态页面的一镜到底动效,并在动画结束时关闭页面,并将该组件迁移至半模态页面。 1312 1313以点击图片展开半模态页的场景为例,实现步骤为: 1314 1315- 在初始界面挂载半模态转场和全模态转场两个页面,半模态页按需布局,全模态页面仅放置一镜到底动效需要的组件,抓取布局信息,使其初始位置为初始界面图片的位置。点击初始界面图片时,同时触发半模态和全模态页面出现,因设置为SheetMode.EMBEDDED模式,此时全模态页面层级最高。 1316 1317- 设置不可见的占位图片置于半模态页上,作为一镜到底动效结束时图片的终止位置。利用[布局回调](../reference/apis-arkui/js-apis-arkui-inspector.md)监听该占位图片布局完成的时候,此时执行回调抓取占位图片的位置信息,随后全模态页面上的图片利用属性动画开始进行共享元素转场。 1318 1319- 全模态页面的动画结束时触发结束回调,关闭全模态页面,将共享元素图片的节点迁移至半模态页面,替换占位图片。 1320 1321- 需注意,半模态页面的弹起高度不同,其页面起始位置也有所不同,而全模态则是全屏显示,两者存在一高度差,做一镜到底动画时,需要计算差值并进行修正,具体可见demo。 1322 1323- 还可以配合一镜到底动画,给初始界面图片也增加一个从透明到出现的动画,使得动效更为流畅。 1324 1325``` 1326├──entry/src/main/ets // 代码区 1327│ ├──entryability 1328│ │ └──EntryAbility.ets // 程序入口类 1329│ ├──NodeContainer 1330│ │ └──CustomComponent.ets // 自定义占位节点 1331│ ├──pages 1332│ │ └──Index.ets // 进行共享元素转场的主页面 1333│ └──utils 1334│ ├──ComponentAttrUtils.ets // 组件位置获取 1335│ └──WindowUtils.ets // 窗口信息 1336└──entry/src/main/resources // 资源文件 1337``` 1338 1339```ts 1340// index.ets 1341import { MyNodeController, createMyNode, getMyNode } from '../NodeContainer/CustomComponent'; 1342import { ComponentAttrUtils, RectInfoInPx } from '../utils/ComponentAttrUtils'; 1343import { WindowUtils } from '../utils/WindowUtils'; 1344import { inspector } from '@kit.ArkUI' 1345 1346class AnimationInfo { 1347 scale: number = 0; 1348 translateX: number = 0; 1349 translateY: number = 0; 1350 clipWidth: Dimension = 0; 1351 clipHeight: Dimension = 0; 1352} 1353 1354@Entry 1355@Component 1356struct Index { 1357 @State isShowSheet: boolean = false; 1358 @State isShowImage: boolean = false; 1359 @State isShowOverlay: boolean = false; 1360 @State isAnimating: boolean = false; 1361 @State isEnabled: boolean = true; 1362 1363 @State scaleValue: number = 0; 1364 @State translateX: number = 0; 1365 @State translateY: number = 0; 1366 @State clipWidth: Dimension = 0; 1367 @State clipHeight: Dimension = 0; 1368 @State radius: number = 0; 1369 // 原图的透明度 1370 @State opacityDegree: number = 1; 1371 1372 // 抓取照片原位置信息 1373 private originInfo: AnimationInfo = new AnimationInfo; 1374 // 抓取照片在半模态页上位置信息 1375 private targetInfo: AnimationInfo = new AnimationInfo; 1376 // 半模态高度 1377 private bindSheetHeight: number = 450; 1378 // 半模态上图片圆角 1379 private sheetRadius: number = 20; 1380 1381 // 设置半模态上图片的布局监听 1382 listener:inspector.ComponentObserver = this.getUIContext().getUIInspector().createComponentObserver('target'); 1383 aboutToAppear(): void { 1384 // 设置半模态上图片的布局完成回调 1385 let onLayoutComplete:()=>void=():void=>{ 1386 // 目标图片布局完成时抓取布局信息 1387 this.targetInfo = this.calculateData('target'); 1388 // 仅半模态正确布局且此时无动画时触发一镜到底动画 1389 if (this.targetInfo.scale != 0 && this.targetInfo.clipWidth != 0 && this.targetInfo.clipHeight != 0 && !this.isAnimating) { 1390 this.isAnimating = true; 1391 // 用于一镜到底的模态页的属性动画 1392 this.getUIContext()?.animateTo({ 1393 duration: 1000, 1394 curve: Curve.Friction, 1395 onFinish: () => { 1396 // 模态转场页(overlay)上的自定义节点下树 1397 this.isShowOverlay = false; 1398 // 半模态上的自定义节点上树,由此完成节点迁移 1399 this.isShowImage = true; 1400 } 1401 }, () => { 1402 this.scaleValue = this.targetInfo.scale; 1403 this.translateX = this.targetInfo.translateX; 1404 this.clipWidth = this.targetInfo.clipWidth; 1405 this.clipHeight = this.targetInfo.clipHeight; 1406 // 修正因半模态高度和缩放导致的高度差 1407 this.translateY = this.targetInfo.translateY + 1408 (this.getUIContext().px2vp(WindowUtils.windowHeight_px) - this.bindSheetHeight 1409 - this.getUIContext().px2vp(WindowUtils.navigationIndicatorHeight_px) - this.getUIContext().px2vp(WindowUtils.topAvoidAreaHeight_px)); 1410 // 修正因缩放导致的圆角差异 1411 this.radius = this.sheetRadius / this.scaleValue 1412 }) 1413 // 原图从透明到出现的动画 1414 this.getUIContext()?.animateTo({ 1415 duration: 2000, 1416 curve: Curve.Friction, 1417 }, () => { 1418 this.opacityDegree = 1; 1419 }) 1420 } 1421 } 1422 // 打开布局监听 1423 this.listener.on('layout', onLayoutComplete) 1424 } 1425 1426 // 获取对应id的组件相对窗口左上角的属性 1427 calculateData(id: string): AnimationInfo { 1428 let itemInfo: RectInfoInPx = 1429 ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), id); 1430 // 首先计算图片的宽高与窗口宽高的比例 1431 let widthScaleRatio = itemInfo.width / WindowUtils.windowWidth_px; 1432 let heightScaleRatio = itemInfo.height / WindowUtils.windowHeight_px; 1433 let isUseWidthScale = widthScaleRatio > heightScaleRatio; 1434 let itemScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio; 1435 let itemTranslateX: number = 0; 1436 let itemClipWidth: Dimension = 0; 1437 let itemClipHeight: Dimension = 0; 1438 let itemTranslateY: number = 0; 1439 1440 if (isUseWidthScale) { 1441 itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidth_px - itemInfo.width) / 2); 1442 itemClipWidth = '100%'; 1443 itemClipHeight = this.getUIContext().px2vp((itemInfo.height) / itemScale); 1444 itemTranslateY = this.getUIContext().px2vp(itemInfo.top - ((this.getUIContext().vp2px(itemClipHeight) - this.getUIContext().vp2px(itemClipHeight) * itemScale) / 2)); 1445 } else { 1446 itemTranslateY = this.getUIContext().px2vp(itemInfo.top - (WindowUtils.windowHeight_px - itemInfo.height) / 2); 1447 itemClipHeight = '100%'; 1448 itemClipWidth = this.getUIContext().px2vp((itemInfo.width) / itemScale); 1449 itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidth_px / 2 - itemInfo.width / 2)); 1450 } 1451 1452 return { 1453 scale: itemScale, 1454 translateX: itemTranslateX , 1455 translateY: itemTranslateY, 1456 clipWidth: itemClipWidth, 1457 clipHeight: itemClipHeight, 1458 } 1459 } 1460 1461 // 照片页 1462 build() { 1463 Column() { 1464 Text('照片') 1465 .textAlign(TextAlign.Start) 1466 .width('100%') 1467 .fontSize(30) 1468 .padding(20) 1469 // 图片使用Resource资源,需用户自定义 1470 Image($r("app.media.flower")) 1471 .opacity(this.opacityDegree) 1472 .width('90%') 1473 .id('origin')// 挂载半模态页 1474 .enabled(this.isEnabled) 1475 .onClick(() => { 1476 // 获取原始图像的位置信息,将模态页上图片移动缩放至该位置 1477 this.originInfo = this.calculateData('origin'); 1478 this.scaleValue = this.originInfo.scale; 1479 this.translateX = this.originInfo.translateX; 1480 this.translateY = this.originInfo.translateY; 1481 this.clipWidth = this.originInfo.clipWidth; 1482 this.clipHeight = this.originInfo.clipHeight; 1483 this.radius = 0; 1484 this.opacityDegree = 0; 1485 // 启动半模态页和模态页 1486 this.isShowSheet = true; 1487 this.isShowOverlay = true; 1488 // 设置原图为不可交互抗打断 1489 this.isEnabled = false; 1490 }) 1491 } 1492 .width('100%') 1493 .height('100%') 1494 .padding({ top: 20 }) 1495 .alignItems(HorizontalAlign.Center) 1496 .bindSheet(this.isShowSheet, this.mySheet(), { 1497 // Embedded模式使得其他页面可以高于半模态页 1498 mode: SheetMode.EMBEDDED, 1499 height: this.bindSheetHeight, 1500 onDisappear: () => { 1501 // 保证半模态消失时状态正确 1502 this.isShowImage = false; 1503 this.isShowSheet = false; 1504 // 设置一镜到底动画又进入可触发状态 1505 this.isAnimating = false; 1506 // 原图重新变为可交互状态 1507 this.isEnabled = true; 1508 } 1509 }) // 挂载模态页作为一镜到底动画的实现页 1510 .bindContentCover(this.isShowOverlay, this.overlayNode(), { 1511 // 模态页面设置为无转场 1512 transition: TransitionEffect.IDENTITY, 1513 }) 1514 } 1515 1516 // 半模态页面 1517 @Builder 1518 mySheet() { 1519 Column({space: 20}) { 1520 Text('半模态页面') 1521 .fontSize(30) 1522 Row({space: 40}) { 1523 Column({space: 20}) { 1524 ForEach([1, 2, 3, 4], () => { 1525 Stack() 1526 .backgroundColor(Color.Pink) 1527 .borderRadius(20) 1528 .width(60) 1529 .height(60) 1530 }) 1531 } 1532 Column() { 1533 if (this.isShowImage) { 1534 // 半模态页面的自定义图片节点 1535 ImageNode() 1536 } 1537 else { 1538 // 抓取布局和占位用,实际不显示 1539 // 图片使用Resource资源,需用户自定义 1540 Image($r("app.media.flower")) 1541 .visibility(Visibility.Hidden) 1542 } 1543 } 1544 .height(300) 1545 .width(200) 1546 .borderRadius(20) 1547 .clip(true) 1548 .id('target') 1549 } 1550 .alignItems(VerticalAlign.Top) 1551 } 1552 .alignItems(HorizontalAlign.Start) 1553 .height('100%') 1554 .width('100%') 1555 .margin(40) 1556 } 1557 1558 @Builder 1559 overlayNode() { 1560 // Stack需要设置alignContent为TopStart,否则在高度变化过程中,截图和内容都会随高度重新布局位置 1561 Stack({ alignContent: Alignment.TopStart }) { 1562 ImageNode() 1563 } 1564 .scale({ x: this.scaleValue, y: this.scaleValue, centerX: undefined, centerY: undefined}) 1565 .translate({ x: this.translateX, y: this.translateY }) 1566 .width(this.clipWidth) 1567 .height(this.clipHeight) 1568 .borderRadius(this.radius) 1569 .clip(true) 1570 } 1571} 1572 1573@Component 1574struct ImageNode { 1575 @State myNodeController: MyNodeController | undefined = new MyNodeController(false); 1576 1577 aboutToAppear(): void { 1578 // 获取自定义节点 1579 let node = getMyNode(); 1580 if (node == undefined) { 1581 // 新建自定义节点 1582 createMyNode(this.getUIContext()); 1583 } 1584 this.myNodeController = getMyNode(); 1585 } 1586 1587 aboutToDisappear(): void { 1588 if (this.myNodeController != undefined) { 1589 // 节点下树 1590 this.myNodeController.onRemove(); 1591 } 1592 } 1593 build() { 1594 NodeContainer(this.myNodeController) 1595 } 1596} 1597``` 1598 1599```ts 1600// CustomComponent.ets 1601// 自定义占位节点,跨容器迁移能力 1602import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI'; 1603 1604@Builder 1605function CardBuilder() { 1606 // 图片使用Resource资源,需用户自定义 1607 Image($r("app.media.flower")) 1608 // 避免第一次加载图片时图片闪烁 1609 .syncLoad(true) 1610} 1611 1612export class MyNodeController extends NodeController { 1613 private CardNode: BuilderNode<[]> | null = null; 1614 private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(CardBuilder); 1615 private needCreate: boolean = false; 1616 private isRemove: boolean = false; 1617 1618 constructor(create: boolean) { 1619 super(); 1620 this.needCreate = create; 1621 } 1622 1623 makeNode(uiContext: UIContext): FrameNode | null { 1624 if(this.isRemove == true){ 1625 return null; 1626 } 1627 if (this.needCreate && this.CardNode == null) { 1628 this.CardNode = new BuilderNode(uiContext); 1629 this.CardNode.build(this.wrapBuilder) 1630 } 1631 if (this.CardNode == null) { 1632 return null; 1633 } 1634 return this.CardNode!.getFrameNode()!; 1635 } 1636 1637 getNode(): BuilderNode<[]> | null { 1638 return this.CardNode; 1639 } 1640 1641 setNode(node: BuilderNode<[]> | null) { 1642 this.CardNode = node; 1643 this.rebuild(); 1644 } 1645 1646 onRemove() { 1647 this.isRemove = true; 1648 this.rebuild(); 1649 this.isRemove = false; 1650 } 1651 1652 init(uiContext: UIContext) { 1653 this.CardNode = new BuilderNode(uiContext); 1654 this.CardNode.build(this.wrapBuilder) 1655 } 1656} 1657 1658let myNode: MyNodeController | undefined; 1659 1660export const createMyNode = 1661 (uiContext: UIContext) => { 1662 myNode = new MyNodeController(false); 1663 myNode.init(uiContext); 1664 } 1665 1666export const getMyNode = (): MyNodeController | undefined => { 1667 return myNode; 1668} 1669``` 1670 1671```ts 1672// ComponentAttrUtils.ets 1673// 获取组件相对窗口的位置 1674import { componentUtils, UIContext } from '@kit.ArkUI'; 1675import { JSON } from '@kit.ArkTS'; 1676 1677export class ComponentAttrUtils { 1678 // 根据组件的id获取组件的位置信息 1679 public static getRectInfoById(context: UIContext, id: string): RectInfoInPx { 1680 if (!context || !id) { 1681 throw Error('object is empty'); 1682 } 1683 let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id); 1684 1685 if (!componentInfo) { 1686 throw Error('object is empty'); 1687 } 1688 1689 let rstRect: RectInfoInPx = new RectInfoInPx(); 1690 const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2; 1691 const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2; 1692 rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap; 1693 rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap; 1694 rstRect.right = 1695 componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap; 1696 rstRect.bottom = 1697 componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap; 1698 rstRect.width = rstRect.right - rstRect.left; 1699 rstRect.height = rstRect.bottom - rstRect.top; 1700 return { 1701 left: rstRect.left, 1702 right: rstRect.right, 1703 top: rstRect.top, 1704 bottom: rstRect.bottom, 1705 width: rstRect.width, 1706 height: rstRect.height 1707 } 1708 } 1709} 1710 1711export class RectInfoInPx { 1712 left: number = 0; 1713 top: number = 0; 1714 right: number = 0; 1715 bottom: number = 0; 1716 width: number = 0; 1717 height: number = 0; 1718} 1719 1720export class RectJson { 1721 $rect: Array<number> = []; 1722} 1723``` 1724 1725```ts 1726// WindowUtils.ets 1727// 窗口信息 1728import { window } from '@kit.ArkUI'; 1729 1730export class WindowUtils { 1731 public static window: window.Window; 1732 public static windowWidth_px: number; 1733 public static windowHeight_px: number; 1734 public static topAvoidAreaHeight_px: number; 1735 public static navigationIndicatorHeight_px: number; 1736} 1737``` 1738 1739```ts 1740// EntryAbility.ets 1741// 程序入口处的onWindowStageCreate增加对窗口宽高等的抓取 1742 1743import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; 1744import { hilog } from '@kit.PerformanceAnalysisKit'; 1745import { display, window } from '@kit.ArkUI'; 1746import { WindowUtils } from '../utils/WindowUtils'; 1747 1748const TAG: string = 'EntryAbility'; 1749 1750export default class EntryAbility extends UIAbility { 1751 private currentBreakPoint: string = ''; 1752 1753 onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { 1754 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate'); 1755 } 1756 1757 onDestroy(): void { 1758 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy'); 1759 } 1760 1761 onWindowStageCreate(windowStage: window.WindowStage): void { 1762 // Main window is created, set main page for this ability 1763 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); 1764 1765 // 获取窗口宽高 1766 WindowUtils.window = windowStage.getMainWindowSync(); 1767 WindowUtils.windowWidth_px = WindowUtils.window.getWindowProperties().windowRect.width; 1768 WindowUtils.windowHeight_px = WindowUtils.window.getWindowProperties().windowRect.height; 1769 1770 this.updateBreakpoint(WindowUtils.windowWidth_px); 1771 1772 // 获取上方避让区(状态栏等)高度 1773 let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); 1774 WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height; 1775 1776 // 获取导航条高度 1777 let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); 1778 WindowUtils.navigationIndicatorHeight_px = navigationArea.bottomRect.height; 1779 1780 hilog.info(0x0000, TAG, 'the width is ' + WindowUtils.windowWidth_px + ' ' + WindowUtils.windowHeight_px + ' ' + 1781 WindowUtils.topAvoidAreaHeight_px + ' ' + WindowUtils.navigationIndicatorHeight_px); 1782 1783 // 监听窗口尺寸、状态栏高度及导航条高度的变化并更新 1784 try { 1785 WindowUtils.window.on('windowSizeChange', (data) => { 1786 hilog.info(0x0000, TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height); 1787 WindowUtils.windowWidth_px = data.width; 1788 WindowUtils.windowHeight_px = data.height; 1789 this.updateBreakpoint(data.width); 1790 AppStorage.setOrCreate('windowSizeChanged', Date.now()) 1791 }) 1792 1793 WindowUtils.window.on('avoidAreaChange', (data) => { 1794 if (data.type == window.AvoidAreaType.TYPE_SYSTEM) { 1795 let topRectHeight = data.area.topRect.height; 1796 hilog.info(0x0000, TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight); 1797 WindowUtils.topAvoidAreaHeight_px = topRectHeight; 1798 } else if (data.type == window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) { 1799 let bottomRectHeight = data.area.bottomRect.height; 1800 hilog.info(0x0000, TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight); 1801 WindowUtils.navigationIndicatorHeight_px = bottomRectHeight; 1802 } 1803 }) 1804 } catch (exception) { 1805 hilog.error(0x0000, TAG, `register failed. code: ${exception.code}, message: ${exception.message}`); 1806 } 1807 1808 windowStage.loadContent('pages/Index', (err) => { 1809 if (err.code) { 1810 hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); 1811 return; 1812 } 1813 hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.'); 1814 }); 1815 } 1816 1817 updateBreakpoint(width: number) { 1818 let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160); 1819 let newBreakPoint: string = ''; 1820 if (windowWidthVp < 400) { 1821 newBreakPoint = 'xs'; 1822 } else if (windowWidthVp < 600) { 1823 newBreakPoint = 'sm'; 1824 } else if (windowWidthVp < 800) { 1825 newBreakPoint = 'md'; 1826 } else { 1827 newBreakPoint = 'lg'; 1828 } 1829 if (this.currentBreakPoint !== newBreakPoint) { 1830 this.currentBreakPoint = newBreakPoint; 1831 // 使用状态变量记录当前断点值 1832 AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint); 1833 } 1834 } 1835 1836 onWindowStageDestroy(): void { 1837 // Main window is destroyed, release UI related resources 1838 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); 1839 } 1840 1841 onForeground(): void { 1842 // Ability has brought to foreground 1843 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground'); 1844 } 1845 1846 onBackground(): void { 1847 // Ability has back to background 1848 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground'); 1849 } 1850} 1851``` 1852 1853 1854 1855## 使用geometryTransition共享元素转场 1856 1857[geometryTransition](../reference/apis-arkui/arkui-ts/ts-transition-animation-geometrytransition.md)用于组件内隐式共享元素转场,在视图状态切换过程中提供丝滑的上下文继承过渡体验。 1858 1859geometryTransition的使用方式为对需要添加一镜到底动效的两个组件使用geometryTransition接口绑定同一id,这样在其中一个组件消失同时另一个组件创建出现的时候,系统会对二者添加一镜到底动效。 1860 1861geometryTransition绑定两个对象的实现方式使得geometryTransition区别于其他方法,最适合用于两个不同对象之间完成一镜到底。 1862 1863### geometryTransition的简单使用 1864 1865对于同一个页面中的两个元素的一镜到底效果,geometryTransition接口的简单使用示例如下: 1866 1867```ts 1868import { curves } from '@kit.ArkUI'; 1869 1870@Entry 1871@Component 1872struct IfElseGeometryTransition { 1873 @State isShow: boolean = false; 1874 1875 build() { 1876 Stack({ alignContent: Alignment.Center }) { 1877 if (this.isShow) { 1878 // 图片使用Resource资源,需用户自定义 1879 Image($r('app.media.spring')) 1880 .autoResize(false) 1881 .clip(true) 1882 .width(200) 1883 .height(200) 1884 .borderRadius(100) 1885 .geometryTransition("picture") 1886 .transition(TransitionEffect.OPACITY) 1887 // 在打断场景下,即动画过程中点击页面触发下一次转场,如果不加id,则会出现重影 1888 // 加了id之后,新建的spring图片会复用之前的spring图片节点,不会重新创建节点,也就不会有重影问题 1889 // 加id的规则为加在if和else下的第一个节点上,有多个并列节点则也需要进行添加 1890 .id('item1') 1891 } else { 1892 // geometryTransition此处绑定的是容器,那么容器内的子组件需设为相对布局跟随父容器变化, 1893 // 套多层容器为了说明相对布局约束传递 1894 Column() { 1895 Column() { 1896 // 图片使用Resource资源,需用户自定义 1897 Image($r('app.media.sky')) 1898 .size({ width: '100%', height: '100%' }) 1899 } 1900 .size({ width: '100%', height: '100%' }) 1901 } 1902 .width(100) 1903 .height(100) 1904 // geometryTransition会同步圆角,但仅限于geometryTransition绑定处,此处绑定的是容器 1905 // 则对容器本身有圆角同步而不会操作容器内部子组件的borderRadius 1906 .borderRadius(50) 1907 .clip(true) 1908 .geometryTransition("picture") 1909 // transition保证节点离场不被立即析构,设置通用转场效果 1910 .transition(TransitionEffect.OPACITY) 1911 .position({ x: 40, y: 40 }) 1912 .id('item2') 1913 } 1914 } 1915 .onClick(() => { 1916 this.getUIContext()?.animateTo({ 1917 curve: curves.springMotion() 1918 }, () => { 1919 this.isShow = !this.isShow; 1920 }) 1921 }) 1922 .size({ width: '100%', height: '100%' }) 1923 } 1924} 1925``` 1926 1927 1928 1929### geometryTransition结合模态转场使用 1930 1931更多的场景中,需要对一个页面的元素与另一个页面的元素添加一镜到底动效。可以通过geometryTransition搭配模态转场接口实现。以点击头像弹出个人信息页的demo为例: 1932 1933```ts 1934class PostData { 1935 // 图片使用Resource资源,需用户自定义 1936 avatar: Resource = $r('app.media.flower'); 1937 name: string = ''; 1938 message: string = ''; 1939 images: Resource[] = []; 1940} 1941 1942@Entry 1943@Component 1944struct Index { 1945 @State isPersonalPageShow: boolean = false; 1946 @State selectedIndex: number = 0; 1947 @State alphaValue: number = 1; 1948 1949 // 数组中图片均使用Resource资源,需用户自定义 1950 private allPostData: PostData[] = [ 1951 { avatar: $r('app.media.flower'), name: 'Alice', message: '天气晴朗', 1952 images: [$r('app.media.spring'), $r('app.media.tree')] }, 1953 { avatar: $r('app.media.sky'), name: 'Bob', message: '你好世界', 1954 images: [$r('app.media.island')] }, 1955 { avatar: $r('app.media.tree'), name: 'Carl', message: '万物生长', 1956 images: [$r('app.media.flower'), $r('app.media.sky'), $r('app.media.spring')] }]; 1957 1958 private onAvatarClicked(index: number): void { 1959 this.selectedIndex = index; 1960 this.getUIContext()?.animateTo({ 1961 duration: 350, 1962 curve: Curve.Friction 1963 }, () => { 1964 this.isPersonalPageShow = !this.isPersonalPageShow; 1965 this.alphaValue = 0; 1966 }); 1967 } 1968 1969 private onPersonalPageBack(index: number): void { 1970 this.getUIContext()?.animateTo({ 1971 duration: 350, 1972 curve: Curve.Friction 1973 }, () => { 1974 this.isPersonalPageShow = !this.isPersonalPageShow; 1975 this.alphaValue = 1; 1976 }); 1977 } 1978 1979 @Builder 1980 PersonalPageBuilder(index: number) { 1981 Column({ space: 20 }) { 1982 Image(this.allPostData[index].avatar) 1983 .size({ width: 200, height: 200 }) 1984 .borderRadius(100) 1985 // 头像配置共享元素效果,与点击的头像的id匹配 1986 .geometryTransition(index.toString()) 1987 .clip(true) 1988 .transition(TransitionEffect.opacity(0.99)) 1989 1990 Text(this.allPostData[index].name) 1991 .font({ size: 30, weight: 600 }) 1992 // 对文本添加出现转场效果 1993 .transition(TransitionEffect.asymmetric( 1994 TransitionEffect.OPACITY 1995 .combine(TransitionEffect.translate({ y: 100 })), 1996 TransitionEffect.OPACITY.animation({ duration: 0 }) 1997 )) 1998 1999 Text('你好,我是' + this.allPostData[index].name) 2000 // 对文本添加出现转场效果 2001 .transition(TransitionEffect.asymmetric( 2002 TransitionEffect.OPACITY 2003 .combine(TransitionEffect.translate({ y: 100 })), 2004 TransitionEffect.OPACITY.animation({ duration: 0 }) 2005 )) 2006 } 2007 .padding({ top: 20 }) 2008 .size({ width: 360, height: 780 }) 2009 .backgroundColor(Color.White) 2010 .onClick(() => { 2011 this.onPersonalPageBack(index); 2012 }) 2013 .transition(TransitionEffect.asymmetric( 2014 TransitionEffect.opacity(0.99), 2015 TransitionEffect.OPACITY 2016 )) 2017 } 2018 2019 build() { 2020 Column({ space: 20 }) { 2021 ForEach(this.allPostData, (postData: PostData, index: number) => { 2022 Column() { 2023 Post({ data: postData, index: index, onAvatarClicked: (index: number) => { this.onAvatarClicked(index) } }) 2024 } 2025 .width('100%') 2026 }, (postData: PostData, index: number) => index.toString()) 2027 } 2028 .size({ width: '100%', height: '100%' }) 2029 .backgroundColor('#40808080') 2030 .bindContentCover(this.isPersonalPageShow, 2031 this.PersonalPageBuilder(this.selectedIndex), { modalTransition: ModalTransition.NONE }) 2032 .opacity(this.alphaValue) 2033 } 2034} 2035 2036@Component 2037export default struct Post { 2038 @Prop data: PostData; 2039 @Prop index: number; 2040 2041 @State expandImageSize: number = 100; 2042 @State avatarSize: number = 50; 2043 2044 private onAvatarClicked: (index: number) => void = (index: number) => { }; 2045 2046 build() { 2047 Column({ space: 20 }) { 2048 Row({ space: 10 }) { 2049 Image(this.data.avatar) 2050 .size({ width: this.avatarSize, height: this.avatarSize }) 2051 .borderRadius(this.avatarSize / 2) 2052 .clip(true) 2053 .onClick(() => { 2054 this.onAvatarClicked(this.index); 2055 }) 2056 // 对头像绑定共享元素转场的id 2057 .geometryTransition(this.index.toString(), {follow:true}) 2058 .transition(TransitionEffect.OPACITY.animation({ duration: 350, curve: Curve.Friction })) 2059 2060 Text(this.data.name) 2061 } 2062 .justifyContent(FlexAlign.Start) 2063 2064 Text(this.data.message) 2065 2066 Row({ space: 15 }) { 2067 ForEach(this.data.images, (imageResource: Resource, index: number) => { 2068 Image(imageResource) 2069 .size({ width: 100, height: 100 }) 2070 }, (imageResource: Resource, index: number) => index.toString()) 2071 } 2072 } 2073 .backgroundColor(Color.White) 2074 .size({ width: '100%', height: 250 }) 2075 .alignItems(HorizontalAlign.Start) 2076 .padding({ left: 10, top: 10 }) 2077 } 2078} 2079``` 2080 2081效果为点击主页的头像后,弹出模态页面显示个人信息,并且两个页面之间的头像做一镜到底动效: 2082 2083 2084 2085<!--RP1--><!--RP1End-->