1# Shared Element Transition 2 3Shared element transition is a type of transition achieved by animating the size and position between styles of the same or similar elements during page switching. 4 5Let's look at an example. After an image is clicked, it disappears, and a new image appears in another position. Because the two images have the same content, we can add shared element transition to them. The figures below show the results with and without a shared element transition. Clearly, the presence of the shared element transition renders the transition natural and smooth. 6 7| 8---|--- 9 10There are multiple methods for implementing the shared element transition. During real-world development, choose the method that best meets the requirements of your project. 11 12Below is a comparison of the various methods available. 13 14| Implementation Method| Description| Use Case| 15| ------ | ---- | ---- | 16| Implement direct transformation without new containers| No route transitions occur, and you need to implement both expanded and collapsed states within a single component. The component hierarchy remains unchanged after the expansion.| Ideal for simple transitions with minimal overhead, such as opening a page that does not involve loading extensive data or components.| 17| Migrate components across new containers| Employ **NodeController** to migrate components between containers. Initially, adjust the translation and scale attributes of the components based on the position and size of the previous and next layouts to ensure that they align with the initial layout, avoiding visual discontinuity. Then, add animations to reset the translation and scale attributes, thereby creating a smooth, uninterrupted transition from the initial to the final layout.| Suitable for scenarios where creating new objects is resource-intensive, such as when a video live-streaming component is clicked to switch to full screen.| 18| Use geometryTransition| Drawing on system capabilities, bind the same ID for components before and after the transition and encapsulating the transition logic within an **animateTo** block. This allows the system to automatically apply a continuous transition effect.| The system synchronizes the dimensions and positions of the bound components and transitions their opacity for a smooth effect. You need to ensure that width and height animations on bound nodes do not cause abrupt changes. Suitable for scenarios where the overhead of creating new nodes is low.| 19 20## Implement Direct Transformation Without New Containers 21 22This method does not create new containers. Instead, it triggers [transition](../reference/apis-arkui/arkui-ts/ts-transition-animation-component.md) by adding or removing components on an existing container and pairs it with the [property animation](./arkts-attribute-animation-apis.md) of components. 23 24This example implements a shared element transition for the scenario where, as a component is expanded, sibling components in the same container disappear or appear. Specifically, property animations are applied to width and height changes of a component before and after the expansion; enter/exit animations are applied to the sibling components as they disappear or disappear. The basic procedure is as follows: 25 261. Build the component to be expanded, and build two pages for it through state variables: one for the normal state and one for the expanded state. 27 28 ```ts 29 class Tmp { 30 set(item: PostData): PostData { 31 return item 32 } 33 } 34 // Build two pages for the normal and expanded states of the same component, which are then used based on the declared state variables. 35 @Component 36 export struct MyExtendView { 37 // Declare the isExpand variable to be synced with the parent component. 38 @Link isExpand: boolean; 39 // You need to implement the list data. 40 @State cardList: Array<PostData> = xxxx; 41 42 build() { 43 List() { 44 // Customize the expanded component as required. 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) // Encapsulated widget, which needs to be implemented by yourself. 55 }) 56 } 57 .width(this.isExpand? 200:500) // Define the attributes of the expanded component as required. 58 .animation({ curve: curves.springMotion()}) // Bind an animation to component attributes. 59 } 60 } 61 ... 62 ``` 63 642. Expand the component to be expanded. Use state variables to control the disappearance or appearance of sibling components, and apply the enter/exit transition to the disappearance and appearance. 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 // Control the appearance or disappearance of sibling components through the isExpand variable, and configure the enter/exit transition. 79 if (!this.isExpand) { 80 Text('Collapse') 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 // Control the appearance or disappearance of sibling components through the isExpand variable, and configure the enter/exit transition. 91 if (this.isExpand) { 92 Text('Expand') 93 .transition(TransitionEffect.translate({y:300}).animation({ curve: curves.springMotion() })) 94 } 95 } 96 ... 97 ``` 98 99Below is the complete sample code and effect. 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: 'It's sunny.', 117 images: [$r('app.media.spring'), $r('app.media.tree')] }, 118 { avatar: $r('app.media.sky'), name: 'Bob', message: 'Hello World', 119 images: [$r('app.media.island')] }, 120 { avatar: $r('app.media.tree'), name: 'Carl', message: 'Everything grows.', 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 // When a post is clicked, other posts disappear from the tree. 139 if (!this.isExpand || this.selectedIndex === index) { 140 Column() { 141 Post({ data: postData, selecteIndex: this.selectedIndex, index: index }) 142 } 143 .width('100%') 144 // Apply opacity and translate transition effects to the disappearing posts. 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('Comments') 192 // Apply enter/exit transition effects to the text in the comments area. 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 // Animate the width and height of the expanded post, and apply animations to the profile picture and image sizes. 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## Creating a Container and Migrating Components Across Containers 231 232Use [NodeContainer](../reference/apis-arkui/arkui-ts/ts-basic-components-nodecontainer.md) and [custom placeholder nodes](arkts-user-defined-place-hoder.md) with [NodeController](../reference/apis-arkui/js-apis-arkui-nodeController.md) for migrating components across different nodes. Then combine the migration with the property animations to achieve shared element transition. This method can be integrated with various transition styles, including navigation transitions ([Navigation](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md)) and sheet transitions ([bindSheet](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#bindsheet)). 233 234### Using with Stack 235 236With the **Stack** container, where later defined components appear on top, you can control the z-order to ensure that the component is on top after being migrated across nodes. For example, in the scenario of expanding and collapsing widgets, the implementation steps are as follows: 237 238- When expanding a widget, obtain the source node (node A)'s position and migrate the components to a higher-level node (node B) with the same position. 239 240- Add a property animation to node B to make it expand and move to the expanded position, creating a shared element transition. 241 242- When collapsing the widget, add a property animation to node B to make it collapse and move back to the position of node A, creating a shared element transition. 243 244- At the end of the animation, use a callback to migrate the components from node B back to node 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 // Create an animation class. 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 // Common parent component for widget collapsed and expanded states 260 Stack() { 261 List({space: 20}) { 262 ForEach(this.listArray, (item: number) => { 263 ListItem() { 264 // Widget collapsed state 265 PostItem({ index: item, AnimationProperties: this.AnimationProperties }) 266 } 267 }) 268 } 269 .clip(false) 270 .alignListItem(ListItemAlign.Center) 271 if (this.AnimationProperties.isExpandPageShow) { 272 // Widget expanded state 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 // Hide detailed content when the widget is collapsed. 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 // Set a callback to trigger when the widget returns from expanded to collapsed state. 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 // The widget node is removed from the tree when collapsed. 310 this.nodeController.onRemove(); 311 } 312 // Trigger the animation for changing from the folded state to the collapsed state. 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 // Show detailed content when the widget is expanded. 323 private showDetailContent: boolean = true; 324 325 aboutToAppear(): void { 326 // Obtain the corresponding widget component by index. 327 this.nodeController = getPostNode(this.AnimationProperties.curIndex.toString()) 328 // Update to show detailed content. 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 // Execute the callback to obtain the widget component from the folded node. 345 this.nodeController.callCallback(); 346 // The widget component of the currently expanded node is removed from the tree. 347 this.nodeController.onRemove(); 348 } 349 // The widget expands to the expanded state node and is removed from the tree. 350 this.AnimationProperties.isExpandPageShow = false; 351 this.AnimationProperties.isEnabled = true; 352 } 353 }, () => { 354 // The widget returns from the expanded state to the collapsed state. 355 this.AnimationProperties.isEnabled = false; 356 this.AnimationProperties.translateX = 0; 357 this.AnimationProperties.translateY = 0; 358 this.AnimationProperties.changedHeight = false; 359 // Update to hide detailed content. 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// Encapsulated animation class. 376@Observed 377class AnimationProperties { 378 public isExpandPageShow: boolean = false; 379 // Control whether the component responds to click events. 380 public isEnabled: boolean = true; 381 // Index of the expanded widget. 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 // Set the position of the widget relative to the parent component after it is expanded. 391 private expandTranslateX: number = 0; 392 private expandTranslateY: number = 0; 393 394 public expandAnimation(index: number): void { 395 // Record the index of the widget in the expanded state. 396 if (index != undefined) { 397 this.curIndex = index; 398 } 399 // Calculate the position of the collapsed widget relative to the parent component. 400 this.calculateData(index.toString()); 401 // The widget in expanded state is added to the tree. 402 this.isExpandPageShow = true; 403 // Property animation for widget expansion. 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 // Obtain the position of the component that needs to be migrated across nodes, and the position of the common parent node before and after the migration, to calculate the animation parameters for the animating component. 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 // Obtain the position information of the component based on its 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// Cross-container migration 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 // Place the cross-container migration component inside @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('Click to expand Item ' + data.item) 479 .fontSize(20) 480 Text('Shared element transition') 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 // Display detailed content in expanded state. 492 if (data.isExpand) { 493 Row() { 494 Text('Expanded') 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 // Create a node, during which the UIContext should be passed. 545 this.node = new BuilderNode(uiContext) 546 // Create an offline component. 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 // Call update to perform an 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 // Trigger rebuild when the component is migrated out of the node. 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### Using with Navigation 602 603You can use the [customNavContentTransition](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md#customnavcontenttransition11) (see [Example 3](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md#example-3)) capability of [Navigation](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md) to implement shared element transition, during which, the component is migrated from the disappearing page to the appearing page. 604 605The following is the procedure for implementing the expanding and collapsing of a thumbnail: 606 607- Configure custom navigation transition animations between **PageOne** and **PageTwo** using **customNavContentTransition**. 608 609- Implement the custom shared element transition with property animations. This is done by capturing the position information of components relative to the window, which allows for the correct matching of the components' positions, scales, and other information on **PageOne** and **PageTwo**, that is, the starting and ending property information for the animation. 610 611- After the thumbnail is clicked, the shared element transitions from **PageOne** to **PageTwo**, triggering a custom animation that expands the element from a thumbnail to full-screen on **PageTwo**. 612 613- When returning to the thumbnail from the full-screen state, a custom transition animation from **PageTwo** to **PageOne** is triggered, animating the shared element from full-screen to the thumbnail state on **PageOne**, and the component is migrated back to **PageOne** after the transition. 614 615``` 616├──entry/src/main/ets // Code directory 617│ ├──CustomTransition 618│ │ ├──AnimationProperties.ets // Encapsulation of shared element transition animation 619│ │ └──CustomNavigationUtils.ets // Custom transition animation configuration for Navigation 620│ ├──entryability 621│ │ └──EntryAbility.ets // Entry point class 622│ ├──NodeContainer 623│ │ └──CustomComponent.ets // Custom placeholder node 624│ ├──pages 625│ │ ├──Index.ets // Navigation page 626│ │ ├──PageOne.ets // Thumbnail page 627│ │ └──PageTwo.ets // Full-screen page 628│ └──utils 629│ ├──ComponentAttrUtils.ets // Component position acquisition 630│ └──WindowUtils.ets // Window information 631└──entry/src/main/resources // Resource files 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 // Allow custom transition for specific pages by name. 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 // Both clicks and returns require custom transitions, so they need to be judged separately. 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 // Control custom transition routes by the names of 'from' and 'to'. 672 if (!this.isCustomTransitionEnabled(from.name, to.name)) { 673 return undefined; 674 } 675 676 // Check whether the transition pages have registered animations to decide whether to perform a custom transition. 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 // After all judgments are made, construct customAnimation for the system side to call and execute the custom transition animation. 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 // Create a custom node. 727 createMyNode(this.getUIContext()); 728 } 729 this.myNodeController = getMyNode(); 730 } 731 732 private doFinishTransition(): void { 733 // Migrate the node back from PageTwo to PageOne when the transition on PageTwo ends. 734 this.myNodeController = getMyNode(); 735 } 736 737 private registerCustomTransition(): void { 738 // Register the custom animation protocol. 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 // The custom node is removed from the tree of 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('Hello World') 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 // The custom node is removed from the tree of 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 // Migrate the custom node to the current page. 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 // Set alignContent to TopStart for Stack; otherwise, during height changes, both the snapshot and content will be repositioned with the height relayout. 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('Expanded content') 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 // Use expandSafeArea to create an immersive effect for Stack, expanding it upwards to the status bar and downwards to the navigation bar. 871 .expandSafeArea([SafeAreaType.SYSTEM]) 872 // Clip the height. 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// Configure custom transition animations for 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 // Register the animation callback for a page, where name is the identifier for the page's animation callback. 921 // animationCallback indicates the animation content to be executed, and timeout indicates the timeout for ending the transition. 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// Add the {"routerMap": "$profile:route_map"} configuration to the project configuration file module.json5. 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// Encapsulation of shared element transition animation 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// Encapsulate the custom shared element transition animation, which can be directly reused by other APIs to reduce workload. 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 // Calculate the ratio of the widget's width and height to the window's width and height. 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 // Ensure that the widget on PageTwo expands to the status bar at the top. 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 // Before the transition animation starts, calculate scale, translate, position, and clip height & width to ensure that the node's position is consistent before and after migration. 1024 console.log(TAG, 'initScale: ' + initScale + ' initTranslateX ' + initTranslateX + 1025 ' initTranslateY ' + initTranslateY + ' initClipWidth ' + initClipWidth + 1026 ' initClipHeight ' + initClipHeight + ' initPositionValue ' + initPositionValue); 1027 // Transition to the new page 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 // The page corner radius matches the system corner radius. 1050 this.radius = DEVICE_BORDER_RADIUS; 1051 this.showDetailContent = true; 1052 }) 1053 1054 animateTo({ 1055 duration: 100, 1056 curve: Curve.Sharp, 1057 }, () => { 1058 // The page background gradually changes from transparent to the set color. 1059 this.navDestinationBgColor = '#00ffffff'; 1060 }) 1061 1062 // Return to the previous page. 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 // The custom node is removed from the tree of 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// Obtain the position of the component relative to the window. 1103import { componentUtils, UIContext } from '@kit.ArkUI'; 1104import { JSON } from '@kit.ArkTS'; 1105 1106export class ComponentAttrUtils { 1107 // Obtain the position information of the component based on its 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// Window information 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// Add capture of window width and height in onWindowStageCreate at the application entry. 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 // Obtain the window width and height. 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 // Obtain the height of the upper avoid area (such as the status bar). 1202 let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); 1203 WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height; 1204 1205 // Obtain the height of the navigation bar. 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 // Listen for changes in the window size, status bar height, and navigation bar height, and update accordingly. 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 // Use the state variable to record the current breakpoint value. 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// Custom placeholder node with cross-container migration capability 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### Using with BindSheet 1356 1357To achieve a seamless transition to a sheet ([bindSheet](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#bindsheet)) with a shared element animation from the initial screen, set the mode in [SheetOptions](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#sheetoptions) to **SheetMode.EMBEDDED**. This ensures that a new page can overlay the sheet, and upon returning, the sheet persists with its content intact. Concurrently, use a full modal transition with [bindContentCover](../reference/apis-arkui/arkui-ts/ts-universal-attributes-modal-transition.md#bindcontentcover) that appears without a transition effect. This page should only include the component that requires the shared element transition. Apply property animation to demonstrate the component's transition from the initial screen to the sheet, then close the page after the animation and migrate the component to the sheet. 1358 1359To implement a shared element transition to a sheet when an image is clicked: 1360 1361- Mount both a sheet and a full-modal transition on the initial screen: Design the sheet as required, and place only the necessary components for the shared element transition on the full-modal page. Capture layout information to position it over the image on the initial screen. When the image is clicked, trigger both the sheet and full-modal pages to appear, with the full-modal set to **SheetMode.EMBEDDED** for the highest layer. 1362 1363- Place an invisible placeholder image on the sheet: This will be the final position for the image after the shared element transition. Use a [layout callback](../reference/apis-arkui/js-apis-arkui-inspector.md) to listen for when the placeholder image's layout is complete, then obtain its position and start the shared element transition with property animation from the full-modal page's image. 1364 1365- End the animation on the full-modal page: When the animation ends, trigger a callback to close the full-modal page and migrate the shared element image node to the sheet, replacing the placeholder. 1366 1367- Account for height differences: The sheet may have varying elevations, affecting its starting position compared to the full-modal, which is full-screen. Calculate and adjust for these height differences during the shared element transition, as demonstrated in the demo. 1368 1369- Enhance with additional animation: Optionally, add an animation to the initial image that transitions from transparent to visible to smooth the overall effect. 1370 1371``` 1372├──entry/src/main/ets // Code directory 1373│ ├──entryability 1374│ │ └──EntryAbility.ets // Entry point class 1375│ ├──NodeContainer 1376│ │ └──CustomComponent.ets // Custom placeholder node 1377│ ├──pages 1378│ │ └──Index.ets // Home page for the shared element transition 1379│ └──utils 1380│ ├──ComponentAttrUtils.ets // Component position acquisition 1381│ └──WindowUtils.ets // Window information 1382└──entry/src/main/resources // Resource files 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 // Original image opacity 1416 @State opacityDegree: number = 1; 1417 1418 // Capture the original position information of the photo. 1419 private originInfo: AnimationInfo = new AnimationInfo; 1420 // Capture the photo's position information on the sheet. 1421 private targetInfo: AnimationInfo = new AnimationInfo; 1422 // Height of the sheet. 1423 private bindSheetHeight: number = 450; 1424 // Image corner radius on the sheet. 1425 private sheetRadius: number = 20; 1426 1427 // Set a layout listener for the image on the sheet. 1428 listener:inspector.ComponentObserver = this.getUIContext().getUIInspector().createComponentObserver('target'); 1429 aboutToAppear(): void { 1430 // Set a callback for when the layout of the image on the sheet is complete. 1431 let onLayoutComplete:()=>void=():void=>{ 1432 // When the target image layout is complete, capture the layout information. 1433 this.targetInfo = this.calculateData('target'); 1434 // Trigger the shared element transition animation only when the sheet is properly laid out and there is no animation currently running. 1435 if (this.targetInfo.scale != 0 && this.targetInfo.clipWidth != 0 && this.targetInfo.clipHeight != 0 && !this.isAnimating) { 1436 this.isAnimating = true; 1437 // Property animation for shared element transition animation of the modal 1438 this.getUIContext()?.animateTo({ 1439 duration: 1000, 1440 curve: Curve.Friction, 1441 onFinish: () => { 1442 // The custom node on the modal transition page (overlay) is removed from the tree. 1443 this.isShowOverlay = false; 1444 // The custom node on the sheet is added to the tree, completing the node migration. 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 // Adjust for height differences caused by sheet height and scaling. 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 // Adjust for corner radius differences caused by scaling. 1457 this.radius = this.sheetRadius / this.scaleValue 1458 }) 1459 // Animate the original image from transparent to fully visible. 1460 this.getUIContext()?.animateTo({ 1461 duration: 2000, 1462 curve: Curve.Friction, 1463 }, () => { 1464 this.opacityDegree = 1; 1465 }) 1466 } 1467 } 1468 // Enable the layout listener. 1469 this.listener.on('layout', onLayoutComplete) 1470 } 1471 1472 // Obtain the attributes of the component with the corresponding ID relative to the upper left corner of the window. 1473 calculateData(id: string): AnimationInfo { 1474 let itemInfo: RectInfoInPx = 1475 ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), id); 1476 // Calculate the ratio of the image's width and height to the window's width and height. 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 // Photo page. 1508 build() { 1509 Column() { 1510 Text('Photo') 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')// Mount the sheet page. 1519 .enabled(this.isEnabled) 1520 .onClick(() => { 1521 // Obtain the position information of the original image, and move and scale the image on the modal page to this position. 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 // Start the sheet and modal pages. 1531 this.isShowSheet = true; 1532 this.isShowOverlay = true; 1533 // Set the original image to be non-interactive and interrupt-resistant. 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 mode allows other pages to be higher than the sheet page. 1543 mode: SheetMode.EMBEDDED, 1544 height: this.bindSheetHeight, 1545 onDisappear: () => { 1546 // Ensure that the state is correct when the sheet disappears. 1547 this.isShowImage = false; 1548 this.isShowSheet = false; 1549 // Set the shared element transition animation to be triggerable again. 1550 this.isAnimating = false; 1551 // The original image becomes interactive again. 1552 this.isEnabled = true; 1553 } 1554 }) // Mount the modal page as the implementation page for the shared element transition animation. 1555 .bindContentCover(this.isShowOverlay, this.overlayNode(), { 1556 // Set the modal page to have no transition. 1557 transition: TransitionEffect.IDENTITY, 1558 }) 1559 } 1560 1561 // Sheet page. 1562 @Builder 1563 mySheet() { 1564 Column({space: 20}) { 1565 Text('Sheet') 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 // Custom image node for the sheet page. 1580 ImageNode() 1581 } 1582 else { 1583 // For capturing layout and placeholder use, not actually displayed. 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 // Set alignContent to TopStart for Stack; otherwise, during height changes, both the snapshot and content will be repositioned with the height relayout. 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 // Obtain the custom node. 1623 let node = getMyNode(); 1624 if (node == undefined) { 1625 // Create a custom node. 1626 createMyNode(this.getUIContext()); 1627 } 1628 this.myNodeController = getMyNode(); 1629 } 1630 1631 aboutToDisappear(): void { 1632 if (this.myNodeController != undefined) { 1633 // The node is removed from the tree. 1634 this.myNodeController.onRemove(); 1635 } 1636 } 1637 build() { 1638 NodeContainer(this.myNodeController) 1639 } 1640} 1641``` 1642 1643```ts 1644// CustomComponent.ets 1645// Custom placeholder node with cross-container migration capability 1646import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI'; 1647 1648@Builder 1649function CardBuilder() { 1650 Image($r("app.media.flower")) 1651 // Prevent flickering of the image during the first load. 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// Obtain the position of the component relative to the window. 1717import { componentUtils, UIContext } from '@kit.ArkUI'; 1718import { JSON } from '@kit.ArkTS'; 1719 1720export class ComponentAttrUtils { 1721 // Obtain the position information of the component based on its 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// Window information 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// Add capture of window width and height in onWindowStageCreate at the application entry. 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 // Obtain the window width and height. 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 // Obtain the height of the upper avoid area (such as the status bar). 1816 let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); 1817 WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height; 1818 1819 // Obtain the height of the navigation bar. 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 // Listen for changes in the window size, status bar height, and navigation bar height, and update accordingly. 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 // Use the state variable to record the current breakpoint value. 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## Using geometryTransition 1899 1900[geometryTransition](../reference/apis-arkui/arkui-ts/ts-transition-animation-geometrytransition.md) facilitates implicit shared element transitions within components, offering a smooth transition experience during view state changes. 1901 1902To use **geometryTransition**, assign the same ID to both components that require the shared element transition. This sets up a seamless animation between them as one component disappears and the other appears. 1903 1904This method is ideal for shared element transitions between two distinct objects. 1905 1906### Simple Use of geometryTransition 1907 1908Below is a simple example of using **geometryTransition** to implement shared element transition for two elements on the same page: 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 // If a new transition is triggered during the animation, ghosting occurs when id is not specified. 1930 // With id specified, the new spring image reuses the previous spring image node instead of creating a new node. Therefore, ghosting does not occur. 1931 // id needs to be added to the first node under if and else. If there are multiple parallel nodes, id needs to be added for all of them. 1932 .id('item1') 1933 } else { 1934 // geometryTransition is bound to a container. Therefore, a relative layout must be configured for the child components of the container. 1935 // The multiple levels of containers here are used to demonstrate passing of relative layout constraints. 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 synchronizes rounded corner settings, but only for the bound component, which is the container in this example. 1946 // In other words, rounded corner settings of the container are synchronized, and those of the child components are not. 1947 .borderRadius(50) 1948 .clip(true) 1949 .geometryTransition("picture") 1950 // transition ensures that the component is not destroyed immediately when it exits. You can customize the transition effect. 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### Combining geometryTransition with Modal Transition 1971 1972By combining **geometryTransition** with a modal transition API, you can implement a shared element transition between two elements on different pages. The following example implements a demo where clicking a profile picture displays the corresponding profile page. 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: 'It's sunny.', 1991 images: [$r('app.media.spring'), $r('app.media.tree')] }, 1992 { avatar: $r('app.media.sky'), name: 'Bob', message: 'Hello World', 1993 images: [$r('app.media.island')] }, 1994 { avatar: $r('app.media.tree'), name: 'Carl', message: 'Everything grows.', 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 // Apply a shared element transition to the profile picture by its 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 // Apply a transition effect to the text. 2032 .transition(TransitionEffect.asymmetric( 2033 TransitionEffect.OPACITY 2034 .combine(TransitionEffect.translate({ y: 100 })), 2035 TransitionEffect.OPACITY.animation({ duration: 0 }) 2036 )) 2037 2038 Text('Hello, this is' + this.allPostData[index].name) 2039 // Apply a transition effect to the text. 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 of the shared element transition bound to the profile picture. 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 2120After a profile picture on the home page is clicked, the corresponding profile page is displayed in a modal, and there is a shared element transition between the profile pictures on the two pages. 2121 2122 2123 2124