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