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 282. 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. 29 30Below is the complete sample code and effect. 31 32```ts 33class PostData { 34 avatar: Resource = $r('app.media.flower'); 35 name: string = ''; 36 message: string = ''; 37 images: Resource[] = []; 38} 39 40@Entry 41@Component 42struct Index { 43 @State isExpand: boolean = false; 44 @State @Watch('onItemClicked') selectedIndex: number = -1; 45 46 private allPostData: PostData[] = [ 47 { avatar: $r('app.media.flower'), name: 'Alice', message: 'It's sunny.', 48 images: [$r('app.media.spring'), $r('app.media.tree')] }, 49 { avatar: $r('app.media.sky'), name: 'Bob', message: 'Hello World', 50 images: [$r('app.media.island')] }, 51 { avatar: $r('app.media.tree'), name: 'Carl', message: 'Everything grows.', 52 images: [$r('app.media.flower'), $r('app.media.sky'), $r('app.media.spring')] }]; 53 54 private onItemClicked(): void { 55 if (this.selectedIndex < 0) { 56 return; 57 } 58 this.getUIContext()?.animateTo({ 59 duration: 350, 60 curve: Curve.Friction 61 }, () => { 62 this.isExpand = !this.isExpand; 63 }); 64 } 65 66 build() { 67 Column({ space: 20 }) { 68 ForEach(this.allPostData, (postData: PostData, index: number) => { 69 // When a post is clicked, other posts disappear from the tree. 70 if (!this.isExpand || this.selectedIndex === index) { 71 Column() { 72 Post({ data: postData, selecteIndex: this.selectedIndex, index: index }) 73 } 74 .width('100%') 75 // Apply opacity and translate transition effects to the disappearing posts. 76 .transition(TransitionEffect.OPACITY 77 .combine(TransitionEffect.translate({ y: index < this.selectedIndex ? -250 : 250 })) 78 .animation({ duration: 350, curve: Curve.Friction})) 79 } 80 }, (postData: PostData, index: number) => index.toString()) 81 } 82 .size({ width: '100%', height: '100%' }) 83 .backgroundColor('#40808080') 84 } 85} 86 87@Component 88export default struct Post { 89 @Link selecteIndex: number; 90 91 @Prop data: PostData; 92 @Prop index: number; 93 94 @State itemHeight: number = 250; 95 @State isExpand: boolean = false; 96 @State expandImageSize: number = 100; 97 @State avatarSize: number = 50; 98 99 build() { 100 Column({ space: 20 }) { 101 Row({ space: 10 }) { 102 Image(this.data.avatar) 103 .size({ width: this.avatarSize, height: this.avatarSize }) 104 .borderRadius(this.avatarSize / 2) 105 .clip(true) 106 107 Text(this.data.name) 108 } 109 .justifyContent(FlexAlign.Start) 110 111 Text(this.data.message) 112 113 Row({ space: 15 }) { 114 ForEach(this.data.images, (imageResource: Resource, index: number) => { 115 Image(imageResource) 116 .size({ width: this.expandImageSize, height: this.expandImageSize }) 117 }, (imageResource: Resource, index: number) => index.toString()) 118 } 119 120 // Additional content for the expanded state 121 if (this.isExpand) { 122 Column() { 123 Text('Comments') 124 // Apply enter/exit transition effects to the text in the comments area. 125 .transition( TransitionEffect.OPACITY 126 .animation({ duration: 350, curve: Curve.Friction })) 127 .padding({ top: 10 }) 128 } 129 .transition(TransitionEffect.asymmetric( 130 TransitionEffect.opacity(0.99) 131 .animation({ duration: 350, curve: Curve.Friction }), 132 TransitionEffect.OPACITY.animation({ duration: 0 }) 133 )) 134 .size({ width: '100%'}) 135 } 136 } 137 .backgroundColor(Color.White) 138 .size({ width: '100%', height: this.itemHeight }) 139 .alignItems(HorizontalAlign.Start) 140 .padding({ left: 10, top: 10 }) 141 .onClick(() => { 142 this.selecteIndex = -1; 143 this.selecteIndex = this.index; 144 this.getUIContext()?.animateTo({ 145 duration: 350, 146 curve: Curve.Friction 147 }, () => { 148 // Animate the width and height of the expanded post, and apply animations to the profile picture and image sizes. 149 this.isExpand = !this.isExpand; 150 this.itemHeight = this.isExpand ? 780 : 250; 151 this.avatarSize = this.isExpand ? 75: 50; 152 this.expandImageSize = (this.isExpand && this.data.images.length > 0) 153 ? (360 - (this.data.images.length + 1) * 15) / this.data.images.length : 100; 154 }) 155 }) 156 } 157} 158``` 159 160 161 162## Creating a Container and Migrating Components Across Containers 163 164Use [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)). 165 166### Using with Stack 167 168With 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: 169 170- 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. 171 172- Add a property animation to node B to make it expand and move to the expanded position, creating a shared element transition. 173 174- 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. 175 176- At the end of the animation, use a callback to migrate the components from node B back to node A. 177 178```ts 179// Index.ets 180import { createPostNode, getPostNode, PostNode } from "../PostNode" 181import { componentUtils, curves } from '@kit.ArkUI'; 182 183@Entry 184@Component 185struct Index { 186 // Create an animation class. 187 @State AnimationProperties: AnimationProperties = new AnimationProperties(); 188 private listArray: Array<number> = [1, 2, 3, 4, 5, 6, 7, 8 ,9, 10]; 189 190 build() { 191 // Common parent component for widget collapsed and expanded states 192 Stack() { 193 List({space: 20}) { 194 ForEach(this.listArray, (item: number) => { 195 ListItem() { 196 // Widget collapsed state 197 PostItem({ index: item, AnimationProperties: this.AnimationProperties }) 198 } 199 }) 200 } 201 .clip(false) 202 .alignListItem(ListItemAlign.Center) 203 if (this.AnimationProperties.isExpandPageShow) { 204 // Widget expanded state 205 ExpandPage({ AnimationProperties: this.AnimationProperties }) 206 } 207 } 208 .key('rootStack') 209 .enabled(this.AnimationProperties.isEnabled) 210 } 211} 212 213@Component 214struct PostItem { 215 @Prop index: number 216 @Link AnimationProperties: AnimationProperties; 217 @State nodeController: PostNode | undefined = undefined; 218 // Hide detailed content when the widget is collapsed. 219 private showDetailContent: boolean = false; 220 221 aboutToAppear(): void { 222 this.nodeController = createPostNode(this.getUIContext(), this.index.toString(), this.showDetailContent); 223 if (this.nodeController != undefined) { 224 // Set a callback to trigger when the widget returns from expanded to collapsed state. 225 this.nodeController.setCallback(this.resetNode.bind(this)); 226 } 227 } 228 resetNode() { 229 this.nodeController = getPostNode(this.index.toString()); 230 } 231 232 build() { 233 Stack() { 234 NodeContainer(this.nodeController) 235 } 236 .width('100%') 237 .height(100) 238 .key(this.index.toString()) 239 .onClick( ()=> { 240 if (this.nodeController != undefined) { 241 // The widget node is removed from the tree when collapsed. 242 this.nodeController.onRemove(); 243 } 244 // Trigger the animation for changing from the folded state to the collapsed state. 245 this.AnimationProperties.expandAnimation(this.index); 246 }) 247 } 248} 249 250@Component 251struct ExpandPage { 252 @Link AnimationProperties: AnimationProperties; 253 @State nodeController: PostNode | undefined = undefined; 254 // Show detailed content when the widget is expanded. 255 private showDetailContent: boolean = true; 256 257 aboutToAppear(): void { 258 // Obtain the corresponding widget component by index. 259 this.nodeController = getPostNode(this.AnimationProperties.curIndex.toString()) 260 // Update to show detailed content. 261 this.nodeController?.update(this.AnimationProperties.curIndex.toString(), this.showDetailContent) 262 } 263 264 build() { 265 Stack() { 266 NodeContainer(this.nodeController) 267 } 268 .width('100%') 269 .height(this.AnimationProperties.changedHeight ? '100%' : 100) 270 .translate({ x: this.AnimationProperties.translateX, y: this.AnimationProperties.translateY }) 271 .position({ x: this.AnimationProperties.positionX, y: this.AnimationProperties.positionY }) 272 .onClick(() => { 273 this.getUIContext()?.animateTo({ curve: curves.springMotion(0.6, 0.9), 274 onFinish: () => { 275 if (this.nodeController != undefined) { 276 // Execute the callback to obtain the widget component from the folded node. 277 this.nodeController.callCallback(); 278 // The widget component of the currently expanded node is removed from the tree. 279 this.nodeController.onRemove(); 280 } 281 // The widget expands to the expanded state node and is removed from the tree. 282 this.AnimationProperties.isExpandPageShow = false; 283 this.AnimationProperties.isEnabled = true; 284 } 285 }, () => { 286 // The widget returns from the expanded state to the collapsed state. 287 this.AnimationProperties.isEnabled = false; 288 this.AnimationProperties.translateX = 0; 289 this.AnimationProperties.translateY = 0; 290 this.AnimationProperties.changedHeight = false; 291 // Update to hide detailed content. 292 this.nodeController?.update(this.AnimationProperties.curIndex.toString(), false); 293 }) 294 }) 295 } 296} 297 298class RectInfo { 299 left: number = 0; 300 top: number = 0; 301 right: number = 0; 302 bottom: number = 0; 303 width: number = 0; 304 height: number = 0; 305} 306 307// Encapsulated animation class. 308@Observed 309class AnimationProperties { 310 public isExpandPageShow: boolean = false; 311 // Control whether the component responds to click events. 312 public isEnabled: boolean = true; 313 // Index of the expanded widget. 314 public curIndex: number = -1; 315 public translateX: number = 0; 316 public translateY: number = 0; 317 public positionX: number = 0; 318 public positionY: number = 0; 319 public changedHeight: boolean = false; 320 private calculatedTranslateX: number = 0; 321 private calculatedTranslateY: number = 0; 322 // Set the position of the widget relative to the parent component after it is expanded. 323 private expandTranslateX: number = 0; 324 private expandTranslateY: number = 0; 325 326 public expandAnimation(index: number): void { 327 // Record the index of the widget in the expanded state. 328 if (index != undefined) { 329 this.curIndex = index; 330 } 331 // Calculate the position of the collapsed widget relative to the parent component. 332 this.calculateData(index.toString()); 333 // The widget in expanded state is added to the tree. 334 this.isExpandPageShow = true; 335 // Property animation for widget expansion. 336 animateTo({ curve: curves.springMotion(0.6, 0.9) 337 }, () => { 338 this.translateX = this.calculatedTranslateX; 339 this.translateY = this.calculatedTranslateY; 340 this.changedHeight = true; 341 }) 342 } 343 344 // 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. 345 public calculateData(key: string): void { 346 let clickedImageInfo = this.getRectInfoById(key); 347 let rootStackInfo = this.getRectInfoById('rootStack'); 348 this.positionX = px2vp(clickedImageInfo.left - rootStackInfo.left); 349 this.positionY = px2vp(clickedImageInfo.top - rootStackInfo.top); 350 this.calculatedTranslateX = px2vp(rootStackInfo.left - clickedImageInfo.left) + this.expandTranslateX; 351 this.calculatedTranslateY = px2vp(rootStackInfo.top - clickedImageInfo.top) + this.expandTranslateY; 352 } 353 354 // Obtain the position information of the component based on its ID. 355 private getRectInfoById(id: string): RectInfo { 356 let componentInfo: componentUtils.ComponentInfo = componentUtils.getRectangleById(id); 357 358 if (!componentInfo) { 359 throw Error('object is empty'); 360 } 361 362 let rstRect: RectInfo = new RectInfo(); 363 const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2; 364 const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2; 365 rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap; 366 rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap; 367 rstRect.right = 368 componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap; 369 rstRect.bottom = 370 componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap; 371 rstRect.width = rstRect.right - rstRect.left; 372 rstRect.height = rstRect.bottom - rstRect.top; 373 374 return { 375 left: rstRect.left, 376 right: rstRect.right, 377 top: rstRect.top, 378 bottom: rstRect.bottom, 379 width: rstRect.width, 380 height: rstRect.height 381 } 382 } 383} 384``` 385 386```ts 387// PostNode.ets 388// Cross-container migration 389import { UIContext } from '@ohos.arkui.UIContext'; 390import { NodeController, BuilderNode, FrameNode } from '@ohos.arkui.node'; 391import { curves } from '@kit.ArkUI'; 392 393class Data { 394 item: string | null = null 395 isExpand: Boolean | false = false 396} 397 398@Builder 399function PostBuilder(data: Data) { 400 // Place the cross-container migration component inside @Builder. 401 Column() { 402 Row() { 403 Row() 404 .backgroundColor(Color.Pink) 405 .borderRadius(20) 406 .width(80) 407 .height(80) 408 409 Column() { 410 Text('Click to expand Item ' + data.item) 411 .fontSize(20) 412 Text('Shared element transition') 413 .fontSize(12) 414 .fontColor(0x909399) 415 } 416 .alignItems(HorizontalAlign.Start) 417 .justifyContent(FlexAlign.SpaceAround) 418 .margin({ left: 10 }) 419 .height(80) 420 } 421 .width('90%') 422 .height(100) 423 // Display detailed content in expanded state. 424 if (data.isExpand) { 425 Row() { 426 Text('Expanded') 427 .fontSize(28) 428 .fontColor(0x909399) 429 .textAlign(TextAlign.Center) 430 .transition(TransitionEffect.OPACITY.animation({ curve: curves.springMotion(0.6, 0.9) })) 431 } 432 .width('90%') 433 .justifyContent(FlexAlign.Center) 434 } 435 } 436 .width('90%') 437 .height('100%') 438 .alignItems(HorizontalAlign.Center) 439 .borderRadius(10) 440 .margin({ top: 15 }) 441 .backgroundColor(Color.White) 442 .shadow({ 443 radius: 20, 444 color: 0x909399, 445 offsetX: 20, 446 offsetY: 10 447 }) 448 449} 450 451class __InternalValue__{ 452 flag:boolean =false; 453}; 454 455export class PostNode extends NodeController { 456 private node: BuilderNode<Data[]> | null = null; 457 private isRemove: __InternalValue__ = new __InternalValue__(); 458 private callback: Function | undefined = undefined 459 private data: Data | null = null 460 461 makeNode(uiContext: UIContext): FrameNode | null { 462 if(this.isRemove.flag == true){ 463 return null; 464 } 465 if (this.node != null) { 466 return this.node.getFrameNode(); 467 } 468 469 return null; 470 } 471 472 init(uiContext: UIContext, id: string, isExpand: boolean) { 473 if (this.node != null) { 474 return; 475 } 476 // Create a node, during which the UIContext should be passed. 477 this.node = new BuilderNode(uiContext) 478 // Create an offline component. 479 this.data = { item: id, isExpand: isExpand } 480 this.node.build(wrapBuilder<Data[]>(PostBuilder), this.data) 481 } 482 483 update(id: string, isExpand: boolean) { 484 if (this.node !== null) { 485 // Call update to perform an update. 486 this.data = { item: id, isExpand: isExpand } 487 this.node.update(this.data); 488 } 489 } 490 491 setCallback(callback: Function | undefined) { 492 this.callback = callback 493 } 494 495 callCallback() { 496 if (this.callback != undefined) { 497 this.callback(); 498 } 499 } 500 501 onRemove(){ 502 this.isRemove.flag = true; 503 // Trigger rebuild when the component is migrated out of the node. 504 this.rebuild(); 505 this.isRemove.flag = false; 506 } 507} 508 509let gNodeMap: Map<string, PostNode | undefined> = new Map(); 510 511export const createPostNode = 512 (uiContext: UIContext, id: string, isExpand: boolean): PostNode | undefined => { 513 let node = new PostNode(); 514 node.init(uiContext, id, isExpand); 515 gNodeMap.set(id, node); 516 return node; 517 } 518 519export const getPostNode = (id: string): PostNode | undefined => { 520 if (!gNodeMap.has(id)) { 521 return undefined 522 } 523 return gNodeMap.get(id); 524} 525 526export const deleteNode = (id: string) => { 527 gNodeMap.delete(id) 528} 529``` 530 531 532 533### Using with Navigation 534 535You 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-setting-an-interactive-transition-animation)) 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. 536 537The following is the procedure for implementing the expanding and collapsing of a thumbnail: 538 539- Configure custom navigation transition animations between **PageOne** and **PageTwo** using **customNavContentTransition**. 540 541- 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. 542 543- 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**. 544 545- 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. 546 547``` 548├──entry/src/main/ets // Code directory 549│ ├──CustomTransition 550│ │ ├──AnimationProperties.ets // Encapsulation of shared element transition animation 551│ │ └──CustomNavigationUtils.ets // Custom transition animation configuration for Navigation 552│ ├──entryability 553│ │ └──EntryAbility.ets // Entry point class 554│ ├──NodeContainer 555│ │ └──CustomComponent.ets // Custom placeholder node 556│ ├──pages 557│ │ ├──Index.ets // Navigation page 558│ │ ├──PageOne.ets // Thumbnail page 559│ │ └──PageTwo.ets // Full-screen page 560│ └──utils 561│ ├──ComponentAttrUtils.ets // Component position acquisition 562│ └──WindowUtils.ets // Window information 563└──entry/src/main/resources // Resource files 564``` 565 566```ts 567// Index.ets 568import { AnimateCallback, CustomTransition } from '../CustomTransition/CustomNavigationUtils'; 569 570const TAG: string = 'Index'; 571 572@Entry 573@Component 574struct Index { 575 private pageInfos: NavPathStack = new NavPathStack(); 576 // Allow custom transition for specific pages by name. 577 private allowedCustomTransitionFromPageName: string[] = ['PageOne']; 578 private allowedCustomTransitionToPageName: string[] = ['PageTwo']; 579 580 aboutToAppear(): void { 581 this.pageInfos.pushPath({ name: 'PageOne' }); 582 } 583 584 private isCustomTransitionEnabled(fromName: string, toName: string): boolean { 585 // Both clicks and returns require custom transitions, so they need to be judged separately. 586 if ((this.allowedCustomTransitionFromPageName.includes(fromName) 587 && this.allowedCustomTransitionToPageName.includes(toName)) 588 || (this.allowedCustomTransitionFromPageName.includes(toName) 589 && this.allowedCustomTransitionToPageName.includes(fromName))) { 590 return true; 591 } 592 return false; 593 } 594 595 build() { 596 Navigation(this.pageInfos) 597 .hideNavBar(true) 598 .customNavContentTransition((from: NavContentInfo, to: NavContentInfo, operation: NavigationOperation) => { 599 if ((!from || !to) || (!from.name || !to.name)) { 600 return undefined; 601 } 602 603 // Control custom transition routes by the names of 'from' and 'to'. 604 if (!this.isCustomTransitionEnabled(from.name, to.name)) { 605 return undefined; 606 } 607 608 // Check whether the transition pages have registered animations to decide whether to perform a custom transition. 609 let fromParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(from.index); 610 let toParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(to.index); 611 if (!fromParam.animation || !toParam.animation) { 612 return undefined; 613 } 614 615 // After all judgments are made, construct customAnimation for the system side to call and execute the custom transition animation. 616 let customAnimation: NavigationAnimatedTransition = { 617 onTransitionEnd: (isSuccess: boolean) => { 618 console.log(TAG, `current transition result is ${isSuccess}`); 619 }, 620 timeout: 2000, 621 transition: (transitionProxy: NavigationTransitionProxy) => { 622 console.log(TAG, 'trigger transition callback'); 623 if (fromParam.animation) { 624 fromParam.animation(operation == NavigationOperation.PUSH, true, transitionProxy); 625 } 626 if (toParam.animation) { 627 toParam.animation(operation == NavigationOperation.PUSH, false, transitionProxy); 628 } 629 } 630 }; 631 return customAnimation; 632 }) 633 } 634} 635``` 636 637```ts 638// PageOne.ets 639import { CustomTransition } from '../CustomTransition/CustomNavigationUtils'; 640import { MyNodeController, createMyNode, getMyNode } from '../NodeContainer/CustomComponent'; 641import { ComponentAttrUtils, RectInfoInPx } from '../utils/ComponentAttrUtils'; 642import { WindowUtils } from '../utils/WindowUtils'; 643 644@Builder 645export function PageOneBuilder() { 646 PageOne(); 647} 648 649@Component 650export struct PageOne { 651 private pageInfos: NavPathStack = new NavPathStack(); 652 private pageId: number = -1; 653 @State myNodeController: MyNodeController | undefined = new MyNodeController(false); 654 655 aboutToAppear(): void { 656 let node = getMyNode(); 657 if (node == undefined) { 658 // Create a custom node. 659 createMyNode(this.getUIContext()); 660 } 661 this.myNodeController = getMyNode(); 662 } 663 664 private doFinishTransition(): void { 665 // Migrate the node back from PageTwo to PageOne when the transition on PageTwo ends. 666 this.myNodeController = getMyNode(); 667 } 668 669 private registerCustomTransition(): void { 670 // Register the custom animation protocol. 671 CustomTransition.getInstance().registerNavParam(this.pageId, 672 (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => {}, 500); 673 } 674 675 private onCardClicked(): void { 676 let cardItemInfo: RectInfoInPx = 677 ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), 'card'); 678 let param: Record<string, Object> = {}; 679 param['cardItemInfo'] = cardItemInfo; 680 param['doDefaultTransition'] = (myController: MyNodeController) => { 681 this.doFinishTransition() 682 }; 683 this.pageInfos.pushPath({ name: 'PageTwo', param: param }); 684 // The custom node is removed from the tree of PageOne. 685 if (this.myNodeController != undefined) { 686 (this.myNodeController as MyNodeController).onRemove(); 687 } 688 } 689 690 build() { 691 NavDestination() { 692 Stack() { 693 Column({ space: 20 }) { 694 Row({ space: 10 }) { 695 Image($r("app.media.avatar")) 696 .size({ width: 50, height: 50 }) 697 .borderRadius(25) 698 .clip(true) 699 700 Text('Alice') 701 } 702 .justifyContent(FlexAlign.Start) 703 704 Text('Hello World') 705 706 NodeContainer(this.myNodeController) 707 .size({ width: 320, height: 250 }) 708 .onClick(() => { 709 this.onCardClicked() 710 }) 711 } 712 .alignItems(HorizontalAlign.Start) 713 .margin(30) 714 } 715 } 716 .onReady((context: NavDestinationContext) => { 717 this.pageInfos = context.pathStack; 718 this.pageId = this.pageInfos.getAllPathName().length - 1; 719 this.registerCustomTransition(); 720 }) 721 .onDisAppear(() => { 722 CustomTransition.getInstance().unRegisterNavParam(this.pageId); 723 // The custom node is removed from the tree of PageOne. 724 if (this.myNodeController != undefined) { 725 (this.myNodeController as MyNodeController).onRemove(); 726 } 727 }) 728 } 729} 730``` 731 732```ts 733// PageTwo.ets 734import { CustomTransition } from '../CustomTransition/CustomNavigationUtils'; 735import { AnimationProperties } from '../CustomTransition/AnimationProperties'; 736import { RectInfoInPx } from '../utils/ComponentAttrUtils'; 737import { getMyNode, MyNodeController } from '../NodeContainer/CustomComponent'; 738 739@Builder 740export function PageTwoBuilder() { 741 PageTwo(); 742} 743 744@Component 745export struct PageTwo { 746 @State pageInfos: NavPathStack = new NavPathStack(); 747 @State AnimationProperties: AnimationProperties = new AnimationProperties(); 748 @State myNodeController: MyNodeController | undefined = new MyNodeController(false); 749 750 private pageId: number = -1; 751 752 private shouldDoDefaultTransition: boolean = false; 753 private prePageDoFinishTransition: () => void = () => {}; 754 private cardItemInfo: RectInfoInPx = new RectInfoInPx(); 755 756 @StorageProp('windowSizeChanged') @Watch('unRegisterNavParam') windowSizeChangedTime: number = 0; 757 @StorageProp('onConfigurationUpdate') @Watch('unRegisterNavParam') onConfigurationUpdateTime: number = 0; 758 759 aboutToAppear(): void { 760 // Migrate the custom node to the current page. 761 this.myNodeController = getMyNode(); 762 } 763 764 private unRegisterNavParam(): void { 765 this.shouldDoDefaultTransition = true; 766 } 767 768 private onBackPressed(): boolean { 769 if (this.shouldDoDefaultTransition) { 770 CustomTransition.getInstance().unRegisterNavParam(this.pageId); 771 this.pageInfos.pop(); 772 this.prePageDoFinishTransition(); 773 this.shouldDoDefaultTransition = false; 774 return true; 775 } 776 this.pageInfos.pop(); 777 return true; 778 } 779 780 build() { 781 NavDestination() { 782 // Set alignContent to TopStart for Stack; otherwise, during height changes, both the snapshot and content will be repositioned with the height relayout. 783 Stack({ alignContent: Alignment.TopStart }) { 784 Stack({ alignContent: Alignment.TopStart }) { 785 Column({space: 20}) { 786 NodeContainer(this.myNodeController) 787 if (this.AnimationProperties.showDetailContent) 788 Text('Expanded content') 789 .fontSize(20) 790 .transition(TransitionEffect.OPACITY) 791 .margin(30) 792 } 793 .alignItems(HorizontalAlign.Start) 794 } 795 .position({ y: this.AnimationProperties.positionValue }) 796 } 797 .scale({ x: this.AnimationProperties.scaleValue, y: this.AnimationProperties.scaleValue }) 798 .translate({ x: this.AnimationProperties.translateX, y: this.AnimationProperties.translateY }) 799 .width(this.AnimationProperties.clipWidth) 800 .height(this.AnimationProperties.clipHeight) 801 .borderRadius(this.AnimationProperties.radius) 802 // Use expandSafeArea to create an immersive effect for Stack, expanding it upwards to the status bar and downwards to the navigation bar. 803 .expandSafeArea([SafeAreaType.SYSTEM]) 804 // Clip the height. 805 .clip(true) 806 } 807 .backgroundColor(this.AnimationProperties.navDestinationBgColor) 808 .hideTitleBar(true) 809 .onReady((context: NavDestinationContext) => { 810 this.pageInfos = context.pathStack; 811 this.pageId = this.pageInfos.getAllPathName().length - 1; 812 let param = context.pathInfo?.param as Record<string, Object>; 813 this.prePageDoFinishTransition = param['doDefaultTransition'] as () => void; 814 this.cardItemInfo = param['cardItemInfo'] as RectInfoInPx; 815 CustomTransition.getInstance().registerNavParam(this.pageId, 816 (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => { 817 this.AnimationProperties.doAnimation( 818 this.cardItemInfo, isPush, isExit, transitionProxy, 0, 819 this.prePageDoFinishTransition, this.myNodeController); 820 }, 500); 821 }) 822 .onBackPressed(() => { 823 return this.onBackPressed(); 824 }) 825 .onDisAppear(() => { 826 CustomTransition.getInstance().unRegisterNavParam(this.pageId); 827 }) 828 } 829} 830``` 831 832```ts 833// CustomNavigationUtils.ets 834// Configure custom transition animations for Navigation. 835export interface AnimateCallback { 836 animation: ((isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void | undefined) 837 | undefined; 838 timeout: (number | undefined) | undefined; 839} 840 841const customTransitionMap: Map<number, AnimateCallback> = new Map(); 842 843export class CustomTransition { 844 private constructor() {}; 845 846 static delegate = new CustomTransition(); 847 848 static getInstance() { 849 return CustomTransition.delegate; 850 } 851 852 // Register the animation callback for a page, where name is the identifier for the page's animation callback. 853 // animationCallback indicates the animation content to be executed, and timeout indicates the timeout for ending the transition. 854 registerNavParam( 855 name: number, 856 animationCallback: (operation: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void, 857 timeout: number): void { 858 if (customTransitionMap.has(name)) { 859 let param = customTransitionMap.get(name); 860 if (param != undefined) { 861 param.animation = animationCallback; 862 param.timeout = timeout; 863 return; 864 } 865 } 866 let params: AnimateCallback = { timeout: timeout, animation: animationCallback }; 867 customTransitionMap.set(name, params); 868 } 869 870 unRegisterNavParam(name: number): void { 871 customTransitionMap.delete(name); 872 } 873 874 getAnimateParam(name: number): AnimateCallback { 875 let result: AnimateCallback = { 876 animation: customTransitionMap.get(name)?.animation, 877 timeout: customTransitionMap.get(name)?.timeout, 878 }; 879 return result; 880 } 881} 882``` 883 884```ts 885// Add the {"routerMap": "$profile:route_map"} configuration to the project configuration file module.json5. 886// route_map.json 887{ 888 "routerMap": [ 889 { 890 "name": "PageOne", 891 "pageSourceFile": "src/main/ets/pages/PageOne.ets", 892 "buildFunction": "PageOneBuilder" 893 }, 894 { 895 "name": "PageTwo", 896 "pageSourceFile": "src/main/ets/pages/PageTwo.ets", 897 "buildFunction": "PageTwoBuilder" 898 } 899 ] 900} 901``` 902 903```ts 904// AnimationProperties.ets 905// Encapsulation of shared element transition animation 906import { curves } from '@kit.ArkUI'; 907import { RectInfoInPx } from '../utils/ComponentAttrUtils'; 908import { WindowUtils } from '../utils/WindowUtils'; 909import { MyNodeController } from '../NodeContainer/CustomComponent'; 910 911const TAG: string = 'AnimationProperties'; 912 913const DEVICE_BORDER_RADIUS: number = 34; 914 915// Encapsulate the custom shared element transition animation, which can be directly reused by other APIs to reduce workload. 916@Observed 917export class AnimationProperties { 918 public navDestinationBgColor: ResourceColor = Color.Transparent; 919 public translateX: number = 0; 920 public translateY: number = 0; 921 public scaleValue: number = 1; 922 public clipWidth: Dimension = 0; 923 public clipHeight: Dimension = 0; 924 public radius: number = 0; 925 public positionValue: number = 0; 926 public showDetailContent: boolean = false; 927 928 public doAnimation(cardItemInfo_px: RectInfoInPx, isPush: boolean, isExit: boolean, 929 transitionProxy: NavigationTransitionProxy, extraTranslateValue: number, prePageOnFinish: (index: MyNodeController) => void, myNodeController: MyNodeController|undefined): void { 930 // Calculate the ratio of the widget's width and height to the window's width and height. 931 let widthScaleRatio = cardItemInfo_px.width / WindowUtils.windowWidth_px; 932 let heightScaleRatio = cardItemInfo_px.height / WindowUtils.windowHeight_px; 933 let isUseWidthScale = widthScaleRatio > heightScaleRatio; 934 let initScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio; 935 936 let initTranslateX: number = 0; 937 let initTranslateY: number = 0; 938 let initClipWidth: Dimension = 0; 939 let initClipHeight: Dimension = 0; 940 // Ensure that the widget on PageTwo expands to the status bar at the top. 941 let initPositionValue: number = -px2vp(WindowUtils.topAvoidAreaHeight_px + extraTranslateValue);; 942 943 if (isUseWidthScale) { 944 initTranslateX = px2vp(cardItemInfo_px.left - (WindowUtils.windowWidth_px - cardItemInfo_px.width) / 2); 945 initClipWidth = '100%'; 946 initClipHeight = px2vp((cardItemInfo_px.height) / initScale); 947 initTranslateY = px2vp(cardItemInfo_px.top - ((vp2px(initClipHeight) - vp2px(initClipHeight) * initScale) / 2)); 948 } else { 949 initTranslateY = px2vp(cardItemInfo_px.top - (WindowUtils.windowHeight_px - cardItemInfo_px.height) / 2); 950 initClipHeight = '100%'; 951 initClipWidth = px2vp((cardItemInfo_px.width) / initScale); 952 initTranslateX = px2vp(cardItemInfo_px.left - (WindowUtils.windowWidth_px / 2 - cardItemInfo_px.width / 2)); 953 } 954 955 // 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. 956 console.log(TAG, 'initScale: ' + initScale + ' initTranslateX ' + initTranslateX + 957 ' initTranslateY ' + initTranslateY + ' initClipWidth ' + initClipWidth + 958 ' initClipHeight ' + initClipHeight + ' initPositionValue ' + initPositionValue); 959 // Transition to the new page 960 if (isPush && !isExit) { 961 this.scaleValue = initScale; 962 this.translateX = initTranslateX; 963 this.clipWidth = initClipWidth; 964 this.clipHeight = initClipHeight; 965 this.translateY = initTranslateY; 966 this.positionValue = initPositionValue; 967 968 animateTo({ 969 curve: curves.interpolatingSpring(0, 1, 328, 36), 970 onFinish: () => { 971 if (transitionProxy) { 972 transitionProxy.finishTransition(); 973 } 974 } 975 }, () => { 976 this.scaleValue = 1.0; 977 this.translateX = 0; 978 this.translateY = 0; 979 this.clipWidth = '100%'; 980 this.clipHeight = '100%'; 981 // The page corner radius matches the system corner radius. 982 this.radius = DEVICE_BORDER_RADIUS; 983 this.showDetailContent = true; 984 }) 985 986 animateTo({ 987 duration: 100, 988 curve: Curve.Sharp, 989 }, () => { 990 // The page background gradually changes from transparent to the set color. 991 this.navDestinationBgColor = '#00ffffff'; 992 }) 993 994 // Return to the previous page. 995 } else if (!isPush && isExit) { 996 997 animateTo({ 998 duration: 350, 999 curve: Curve.EaseInOut, 1000 onFinish: () => { 1001 if (transitionProxy) { 1002 transitionProxy.finishTransition(); 1003 } 1004 prePageOnFinish(myNodeController); 1005 // The custom node is removed from the tree of PageTwo. 1006 if (myNodeController != undefined) { 1007 (myNodeController as MyNodeController).onRemove(); 1008 } 1009 } 1010 }, () => { 1011 this.scaleValue = initScale; 1012 this.translateX = initTranslateX; 1013 this.translateY = initTranslateY; 1014 this.radius = 0; 1015 this.clipWidth = initClipWidth; 1016 this.clipHeight = initClipHeight; 1017 this.showDetailContent = false; 1018 }) 1019 1020 animateTo({ 1021 duration: 200, 1022 delay: 150, 1023 curve: Curve.Friction, 1024 }, () => { 1025 this.navDestinationBgColor = Color.Transparent; 1026 }) 1027 } 1028 } 1029} 1030``` 1031 1032```ts 1033// ComponentAttrUtils.ets 1034// Obtain the position of the component relative to the window. 1035import { componentUtils, UIContext } from '@kit.ArkUI'; 1036import { JSON } from '@kit.ArkTS'; 1037 1038export class ComponentAttrUtils { 1039 // Obtain the position information of the component based on its ID. 1040 public static getRectInfoById(context: UIContext, id: string): RectInfoInPx { 1041 if (!context || !id) { 1042 throw Error('object is empty'); 1043 } 1044 let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id); 1045 1046 if (!componentInfo) { 1047 throw Error('object is empty'); 1048 } 1049 1050 let rstRect: RectInfoInPx = new RectInfoInPx(); 1051 const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2; 1052 const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2; 1053 rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap; 1054 rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap; 1055 rstRect.right = 1056 componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap; 1057 rstRect.bottom = 1058 componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap; 1059 rstRect.width = rstRect.right - rstRect.left; 1060 rstRect.height = rstRect.bottom - rstRect.top; 1061 return { 1062 left: rstRect.left, 1063 right: rstRect.right, 1064 top: rstRect.top, 1065 bottom: rstRect.bottom, 1066 width: rstRect.width, 1067 height: rstRect.height 1068 } 1069 } 1070} 1071 1072export class RectInfoInPx { 1073 left: number = 0; 1074 top: number = 0; 1075 right: number = 0; 1076 bottom: number = 0; 1077 width: number = 0; 1078 height: number = 0; 1079} 1080 1081export class RectJson { 1082 $rect: Array<number> = []; 1083} 1084``` 1085 1086```ts 1087// WindowUtils.ets 1088// Window information 1089import { window } from '@kit.ArkUI'; 1090 1091export class WindowUtils { 1092 public static window: window.Window; 1093 public static windowWidth_px: number; 1094 public static windowHeight_px: number; 1095 public static topAvoidAreaHeight_px: number; 1096 public static navigationIndicatorHeight_px: number; 1097} 1098``` 1099 1100```ts 1101// EntryAbility.ets 1102// Add capture of window width and height in onWindowStageCreate at the application entry. 1103 1104import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; 1105import { hilog } from '@kit.PerformanceAnalysisKit'; 1106import { display, window } from '@kit.ArkUI'; 1107import { WindowUtils } from '../utils/WindowUtils'; 1108 1109const TAG: string = 'EntryAbility'; 1110 1111export default class EntryAbility extends UIAbility { 1112 private currentBreakPoint: string = ''; 1113 1114 onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { 1115 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate'); 1116 } 1117 1118 onDestroy(): void { 1119 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy'); 1120 } 1121 1122 onWindowStageCreate(windowStage: window.WindowStage): void { 1123 // Main window is created, set main page for this ability 1124 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); 1125 1126 // Obtain the window width and height. 1127 WindowUtils.window = windowStage.getMainWindowSync(); 1128 WindowUtils.windowWidth_px = WindowUtils.window.getWindowProperties().windowRect.width; 1129 WindowUtils.windowHeight_px = WindowUtils.window.getWindowProperties().windowRect.height; 1130 1131 this.updateBreakpoint(WindowUtils.windowWidth_px); 1132 1133 // Obtain the height of the upper avoid area (such as the status bar). 1134 let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); 1135 WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height; 1136 1137 // Obtain the height of the navigation bar. 1138 let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); 1139 WindowUtils.navigationIndicatorHeight_px = navigationArea.bottomRect.height; 1140 1141 console.log(TAG, 'the width is ' + WindowUtils.windowWidth_px + ' ' + WindowUtils.windowHeight_px + ' ' + 1142 WindowUtils.topAvoidAreaHeight_px + ' ' + WindowUtils.navigationIndicatorHeight_px); 1143 1144 // Listen for changes in the window size, status bar height, and navigation bar height, and update accordingly. 1145 try { 1146 WindowUtils.window.on('windowSizeChange', (data) => { 1147 console.log(TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height); 1148 WindowUtils.windowWidth_px = data.width; 1149 WindowUtils.windowHeight_px = data.height; 1150 this.updateBreakpoint(data.width); 1151 AppStorage.setOrCreate('windowSizeChanged', Date.now()) 1152 }) 1153 1154 WindowUtils.window.on('avoidAreaChange', (data) => { 1155 if (data.type == window.AvoidAreaType.TYPE_SYSTEM) { 1156 let topRectHeight = data.area.topRect.height; 1157 console.log(TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight); 1158 WindowUtils.topAvoidAreaHeight_px = topRectHeight; 1159 } else if (data.type == window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) { 1160 let bottomRectHeight = data.area.bottomRect.height; 1161 console.log(TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight); 1162 WindowUtils.navigationIndicatorHeight_px = bottomRectHeight; 1163 } 1164 }) 1165 } catch (exception) { 1166 console.log('register failed ' + JSON.stringify(exception)); 1167 } 1168 1169 windowStage.loadContent('pages/Index', (err) => { 1170 if (err.code) { 1171 hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); 1172 return; 1173 } 1174 hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.'); 1175 }); 1176 } 1177 1178 updateBreakpoint(width: number) { 1179 let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160); 1180 let newBreakPoint: string = ''; 1181 if (windowWidthVp < 400) { 1182 newBreakPoint = 'xs'; 1183 } else if (windowWidthVp < 600) { 1184 newBreakPoint = 'sm'; 1185 } else if (windowWidthVp < 800) { 1186 newBreakPoint = 'md'; 1187 } else { 1188 newBreakPoint = 'lg'; 1189 } 1190 if (this.currentBreakPoint !== newBreakPoint) { 1191 this.currentBreakPoint = newBreakPoint; 1192 // Use the state variable to record the current breakpoint value. 1193 AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint); 1194 } 1195 } 1196 1197 onWindowStageDestroy(): void { 1198 // Main window is destroyed, release UI related resources 1199 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); 1200 } 1201 1202 onForeground(): void { 1203 // Ability has brought to foreground 1204 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground'); 1205 } 1206 1207 onBackground(): void { 1208 // Ability has back to background 1209 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground'); 1210 } 1211} 1212``` 1213 1214```ts 1215// CustomComponent.ets 1216// Custom placeholder node with cross-container migration capability 1217import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI'; 1218 1219@Builder 1220function CardBuilder() { 1221 Image($r("app.media.card")) 1222 .width('100%') 1223 .id('card') 1224} 1225 1226export class MyNodeController extends NodeController { 1227 private CardNode: BuilderNode<[]> | null = null; 1228 private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(CardBuilder); 1229 private needCreate: boolean = false; 1230 private isRemove: boolean = false; 1231 1232 constructor(create: boolean) { 1233 super(); 1234 this.needCreate = create; 1235 } 1236 1237 makeNode(uiContext: UIContext): FrameNode | null { 1238 if(this.isRemove == true){ 1239 return null; 1240 } 1241 if (this.needCreate && this.CardNode == null) { 1242 this.CardNode = new BuilderNode(uiContext); 1243 this.CardNode.build(this.wrapBuilder) 1244 } 1245 if (this.CardNode == null) { 1246 return null; 1247 } 1248 return this.CardNode!.getFrameNode()!; 1249 } 1250 1251 getNode(): BuilderNode<[]> | null { 1252 return this.CardNode; 1253 } 1254 1255 setNode(node: BuilderNode<[]> | null) { 1256 this.CardNode = node; 1257 this.rebuild(); 1258 } 1259 1260 onRemove() { 1261 this.isRemove = true; 1262 this.rebuild(); 1263 this.isRemove = false; 1264 } 1265 1266 init(uiContext: UIContext) { 1267 this.CardNode = new BuilderNode(uiContext); 1268 this.CardNode.build(this.wrapBuilder) 1269 } 1270} 1271 1272let myNode: MyNodeController | undefined; 1273 1274export const createMyNode = 1275 (uiContext: UIContext) => { 1276 myNode = new MyNodeController(false); 1277 myNode.init(uiContext); 1278 } 1279 1280export const getMyNode = (): MyNodeController | undefined => { 1281 return myNode; 1282} 1283``` 1284 1285 1286 1287### Using with BindSheet 1288 1289To 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. 1290 1291To implement a shared element transition to a sheet when an image is clicked: 1292 1293- 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. 1294 1295- 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. 1296 1297- 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. 1298 1299- 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. 1300 1301- Enhance with additional animation: Optionally, add an animation to the initial image that transitions from transparent to visible to smooth the overall effect. 1302 1303``` 1304├──entry/src/main/ets // Code directory 1305│ ├──entryability 1306│ │ └──EntryAbility.ets // Entry point class 1307│ ├──NodeContainer 1308│ │ └──CustomComponent.ets // Custom placeholder node 1309│ ├──pages 1310│ │ └──Index.ets // Home page for the shared element transition 1311│ └──utils 1312│ ├──ComponentAttrUtils.ets // Component position acquisition 1313│ └──WindowUtils.ets // Window information 1314└──entry/src/main/resources // Resource files 1315``` 1316 1317```ts 1318// index.ets 1319import { MyNodeController, createMyNode, getMyNode } from '../NodeContainer/CustomComponent'; 1320import { ComponentAttrUtils, RectInfoInPx } from '../utils/ComponentAttrUtils'; 1321import { WindowUtils } from '../utils/WindowUtils'; 1322import { inspector } from '@kit.ArkUI' 1323 1324class AnimationInfo { 1325 scale: number = 0; 1326 translateX: number = 0; 1327 translateY: number = 0; 1328 clipWidth: Dimension = 0; 1329 clipHeight: Dimension = 0; 1330} 1331 1332@Entry 1333@Component 1334struct Index { 1335 @State isShowSheet: boolean = false; 1336 @State isShowImage: boolean = false; 1337 @State isShowOverlay: boolean = false; 1338 @State isAnimating: boolean = false; 1339 @State isEnabled: boolean = true; 1340 1341 @State scaleValue: number = 0; 1342 @State translateX: number = 0; 1343 @State translateY: number = 0; 1344 @State clipWidth: Dimension = 0; 1345 @State clipHeight: Dimension = 0; 1346 @State radius: number = 0; 1347 // Original image opacity 1348 @State opacityDegree: number = 1; 1349 1350 // Capture the original position information of the photo. 1351 private originInfo: AnimationInfo = new AnimationInfo; 1352 // Capture the photo's position information on the sheet. 1353 private targetInfo: AnimationInfo = new AnimationInfo; 1354 // Height of the sheet. 1355 private bindSheetHeight: number = 450; 1356 // Image corner radius on the sheet. 1357 private sheetRadius: number = 20; 1358 1359 // Set a layout listener for the image on the sheet. 1360 listener:inspector.ComponentObserver = this.getUIContext().getUIInspector().createComponentObserver('target'); 1361 aboutToAppear(): void { 1362 // Set a callback for when the layout of the image on the sheet is complete. 1363 let onLayoutComplete:()=>void=():void=>{ 1364 // When the target image layout is complete, capture the layout information. 1365 this.targetInfo = this.calculateData('target'); 1366 // Trigger the shared element transition animation only when the sheet is properly laid out and there is no animation currently running. 1367 if (this.targetInfo.scale != 0 && this.targetInfo.clipWidth != 0 && this.targetInfo.clipHeight != 0 && !this.isAnimating) { 1368 this.isAnimating = true; 1369 // Property animation for shared element transition animation of the modal 1370 this.getUIContext()?.animateTo({ 1371 duration: 1000, 1372 curve: Curve.Friction, 1373 onFinish: () => { 1374 // The custom node on the modal transition page (overlay) is removed from the tree. 1375 this.isShowOverlay = false; 1376 // The custom node on the sheet is added to the tree, completing the node migration. 1377 this.isShowImage = true; 1378 } 1379 }, () => { 1380 this.scaleValue = this.targetInfo.scale; 1381 this.translateX = this.targetInfo.translateX; 1382 this.clipWidth = this.targetInfo.clipWidth; 1383 this.clipHeight = this.targetInfo.clipHeight; 1384 // Adjust for height differences caused by sheet height and scaling. 1385 this.translateY = this.targetInfo.translateY + 1386 (this.getUIContext().px2vp(WindowUtils.windowHeight_px) - this.bindSheetHeight 1387 - this.getUIContext().px2vp(WindowUtils.navigationIndicatorHeight_px) - this.getUIContext().px2vp(WindowUtils.topAvoidAreaHeight_px)); 1388 // Adjust for corner radius differences caused by scaling. 1389 this.radius = this.sheetRadius / this.scaleValue 1390 }) 1391 // Animate the original image from transparent to fully visible. 1392 this.getUIContext()?.animateTo({ 1393 duration: 2000, 1394 curve: Curve.Friction, 1395 }, () => { 1396 this.opacityDegree = 1; 1397 }) 1398 } 1399 } 1400 // Enable the layout listener. 1401 this.listener.on('layout', onLayoutComplete) 1402 } 1403 1404 // Obtain the attributes of the component with the corresponding ID relative to the upper left corner of the window. 1405 calculateData(id: string): AnimationInfo { 1406 let itemInfo: RectInfoInPx = 1407 ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), id); 1408 // Calculate the ratio of the image's width and height to the window's width and height. 1409 let widthScaleRatio = itemInfo.width / WindowUtils.windowWidth_px; 1410 let heightScaleRatio = itemInfo.height / WindowUtils.windowHeight_px; 1411 let isUseWidthScale = widthScaleRatio > heightScaleRatio; 1412 let itemScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio; 1413 let itemTranslateX: number = 0; 1414 let itemClipWidth: Dimension = 0; 1415 let itemClipHeight: Dimension = 0; 1416 let itemTranslateY: number = 0; 1417 1418 if (isUseWidthScale) { 1419 itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidth_px - itemInfo.width) / 2); 1420 itemClipWidth = '100%'; 1421 itemClipHeight = this.getUIContext().px2vp((itemInfo.height) / itemScale); 1422 itemTranslateY = this.getUIContext().px2vp(itemInfo.top - ((this.getUIContext().vp2px(itemClipHeight) - this.getUIContext().vp2px(itemClipHeight) * itemScale) / 2)); 1423 } else { 1424 itemTranslateY = this.getUIContext().px2vp(itemInfo.top - (WindowUtils.windowHeight_px - itemInfo.height) / 2); 1425 itemClipHeight = '100%'; 1426 itemClipWidth = this.getUIContext().px2vp((itemInfo.width) / itemScale); 1427 itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidth_px / 2 - itemInfo.width / 2)); 1428 } 1429 1430 return { 1431 scale: itemScale, 1432 translateX: itemTranslateX , 1433 translateY: itemTranslateY, 1434 clipWidth: itemClipWidth, 1435 clipHeight: itemClipHeight, 1436 } 1437 } 1438 1439 // Photo page. 1440 build() { 1441 Column() { 1442 Text('Photo') 1443 .textAlign(TextAlign.Start) 1444 .width('100%') 1445 .fontSize(30) 1446 .padding(20) 1447 Image($r("app.media.flower")) 1448 .opacity(this.opacityDegree) 1449 .width('90%') 1450 .id('origin')// Mount the sheet page. 1451 .enabled(this.isEnabled) 1452 .onClick(() => { 1453 // Obtain the position information of the original image, and move and scale the image on the modal page to this position. 1454 this.originInfo = this.calculateData('origin'); 1455 this.scaleValue = this.originInfo.scale; 1456 this.translateX = this.originInfo.translateX; 1457 this.translateY = this.originInfo.translateY; 1458 this.clipWidth = this.originInfo.clipWidth; 1459 this.clipHeight = this.originInfo.clipHeight; 1460 this.radius = 0; 1461 this.opacityDegree = 0; 1462 // Start the sheet and modal pages. 1463 this.isShowSheet = true; 1464 this.isShowOverlay = true; 1465 // Set the original image to be non-interactive and interrupt-resistant. 1466 this.isEnabled = false; 1467 }) 1468 } 1469 .width('100%') 1470 .height('100%') 1471 .padding({ top: 20 }) 1472 .alignItems(HorizontalAlign.Center) 1473 .bindSheet(this.isShowSheet, this.mySheet(), { 1474 // EMBEDDED mode allows other pages to be higher than the sheet page. 1475 mode: SheetMode.EMBEDDED, 1476 height: this.bindSheetHeight, 1477 onDisappear: () => { 1478 // Ensure that the state is correct when the sheet disappears. 1479 this.isShowImage = false; 1480 this.isShowSheet = false; 1481 // Set the shared element transition animation to be triggerable again. 1482 this.isAnimating = false; 1483 // The original image becomes interactive again. 1484 this.isEnabled = true; 1485 } 1486 }) // Mount the modal page as the implementation page for the shared element transition animation. 1487 .bindContentCover(this.isShowOverlay, this.overlayNode(), { 1488 // Set the modal page to have no transition. 1489 transition: TransitionEffect.IDENTITY, 1490 }) 1491 } 1492 1493 // Sheet page. 1494 @Builder 1495 mySheet() { 1496 Column({space: 20}) { 1497 Text('Sheet') 1498 .fontSize(30) 1499 Row({space: 40}) { 1500 Column({space: 20}) { 1501 ForEach([1, 2, 3, 4], () => { 1502 Stack() 1503 .backgroundColor(Color.Pink) 1504 .borderRadius(20) 1505 .width(60) 1506 .height(60) 1507 }) 1508 } 1509 Column() { 1510 if (this.isShowImage) { 1511 // Custom image node for the sheet page. 1512 ImageNode() 1513 } 1514 else { 1515 // For capturing layout and placeholder use, not actually displayed. 1516 Image($r("app.media.flower")) 1517 .visibility(Visibility.Hidden) 1518 } 1519 } 1520 .height(300) 1521 .width(200) 1522 .borderRadius(20) 1523 .clip(true) 1524 .id('target') 1525 } 1526 .alignItems(VerticalAlign.Top) 1527 } 1528 .alignItems(HorizontalAlign.Start) 1529 .height('100%') 1530 .width('100%') 1531 .margin(40) 1532 } 1533 1534 @Builder 1535 overlayNode() { 1536 // Set alignContent to TopStart for Stack; otherwise, during height changes, both the snapshot and content will be repositioned with the height relayout. 1537 Stack({ alignContent: Alignment.TopStart }) { 1538 ImageNode() 1539 } 1540 .scale({ x: this.scaleValue, y: this.scaleValue, centerX: undefined, centerY: undefined}) 1541 .translate({ x: this.translateX, y: this.translateY }) 1542 .width(this.clipWidth) 1543 .height(this.clipHeight) 1544 .borderRadius(this.radius) 1545 .clip(true) 1546 } 1547} 1548 1549@Component 1550struct ImageNode { 1551 @State myNodeController: MyNodeController | undefined = new MyNodeController(false); 1552 1553 aboutToAppear(): void { 1554 // Obtain the custom node. 1555 let node = getMyNode(); 1556 if (node == undefined) { 1557 // Create a custom node. 1558 createMyNode(this.getUIContext()); 1559 } 1560 this.myNodeController = getMyNode(); 1561 } 1562 1563 aboutToDisappear(): void { 1564 if (this.myNodeController != undefined) { 1565 // The node is removed from the tree. 1566 this.myNodeController.onRemove(); 1567 } 1568 } 1569 build() { 1570 NodeContainer(this.myNodeController) 1571 } 1572} 1573``` 1574 1575```ts 1576// CustomComponent.ets 1577// Custom placeholder node with cross-container migration capability 1578import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI'; 1579 1580@Builder 1581function CardBuilder() { 1582 Image($r("app.media.flower")) 1583 // Prevent flickering of the image during the first load. 1584 .syncLoad(true) 1585} 1586 1587export class MyNodeController extends NodeController { 1588 private CardNode: BuilderNode<[]> | null = null; 1589 private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(CardBuilder); 1590 private needCreate: boolean = false; 1591 private isRemove: boolean = false; 1592 1593 constructor(create: boolean) { 1594 super(); 1595 this.needCreate = create; 1596 } 1597 1598 makeNode(uiContext: UIContext): FrameNode | null { 1599 if(this.isRemove == true){ 1600 return null; 1601 } 1602 if (this.needCreate && this.CardNode == null) { 1603 this.CardNode = new BuilderNode(uiContext); 1604 this.CardNode.build(this.wrapBuilder) 1605 } 1606 if (this.CardNode == null) { 1607 return null; 1608 } 1609 return this.CardNode!.getFrameNode()!; 1610 } 1611 1612 getNode(): BuilderNode<[]> | null { 1613 return this.CardNode; 1614 } 1615 1616 setNode(node: BuilderNode<[]> | null) { 1617 this.CardNode = node; 1618 this.rebuild(); 1619 } 1620 1621 onRemove() { 1622 this.isRemove = true; 1623 this.rebuild(); 1624 this.isRemove = false; 1625 } 1626 1627 init(uiContext: UIContext) { 1628 this.CardNode = new BuilderNode(uiContext); 1629 this.CardNode.build(this.wrapBuilder) 1630 } 1631} 1632 1633let myNode: MyNodeController | undefined; 1634 1635export const createMyNode = 1636 (uiContext: UIContext) => { 1637 myNode = new MyNodeController(false); 1638 myNode.init(uiContext); 1639 } 1640 1641export const getMyNode = (): MyNodeController | undefined => { 1642 return myNode; 1643} 1644``` 1645 1646```ts 1647// ComponentAttrUtils.ets 1648// Obtain the position of the component relative to the window. 1649import { componentUtils, UIContext } from '@kit.ArkUI'; 1650import { JSON } from '@kit.ArkTS'; 1651 1652export class ComponentAttrUtils { 1653 // Obtain the position information of the component based on its ID. 1654 public static getRectInfoById(context: UIContext, id: string): RectInfoInPx { 1655 if (!context || !id) { 1656 throw Error('object is empty'); 1657 } 1658 let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id); 1659 1660 if (!componentInfo) { 1661 throw Error('object is empty'); 1662 } 1663 1664 let rstRect: RectInfoInPx = new RectInfoInPx(); 1665 const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2; 1666 const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2; 1667 rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap; 1668 rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap; 1669 rstRect.right = 1670 componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap; 1671 rstRect.bottom = 1672 componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap; 1673 rstRect.width = rstRect.right - rstRect.left; 1674 rstRect.height = rstRect.bottom - rstRect.top; 1675 return { 1676 left: rstRect.left, 1677 right: rstRect.right, 1678 top: rstRect.top, 1679 bottom: rstRect.bottom, 1680 width: rstRect.width, 1681 height: rstRect.height 1682 } 1683 } 1684} 1685 1686export class RectInfoInPx { 1687 left: number = 0; 1688 top: number = 0; 1689 right: number = 0; 1690 bottom: number = 0; 1691 width: number = 0; 1692 height: number = 0; 1693} 1694 1695export class RectJson { 1696 $rect: Array<number> = []; 1697} 1698``` 1699 1700```ts 1701// WindowUtils.ets 1702// Window information 1703import { window } from '@kit.ArkUI'; 1704 1705export class WindowUtils { 1706 public static window: window.Window; 1707 public static windowWidth_px: number; 1708 public static windowHeight_px: number; 1709 public static topAvoidAreaHeight_px: number; 1710 public static navigationIndicatorHeight_px: number; 1711} 1712``` 1713 1714```ts 1715// EntryAbility.ets 1716// Add capture of window width and height in onWindowStageCreate at the application entry. 1717 1718import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; 1719import { hilog } from '@kit.PerformanceAnalysisKit'; 1720import { display, window } from '@kit.ArkUI'; 1721import { WindowUtils } from '../utils/WindowUtils'; 1722 1723const TAG: string = 'EntryAbility'; 1724 1725export default class EntryAbility extends UIAbility { 1726 private currentBreakPoint: string = ''; 1727 1728 onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { 1729 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate'); 1730 } 1731 1732 onDestroy(): void { 1733 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy'); 1734 } 1735 1736 onWindowStageCreate(windowStage: window.WindowStage): void { 1737 // Main window is created, set main page for this ability 1738 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); 1739 1740 // Obtain the window width and height. 1741 WindowUtils.window = windowStage.getMainWindowSync(); 1742 WindowUtils.windowWidth_px = WindowUtils.window.getWindowProperties().windowRect.width; 1743 WindowUtils.windowHeight_px = WindowUtils.window.getWindowProperties().windowRect.height; 1744 1745 this.updateBreakpoint(WindowUtils.windowWidth_px); 1746 1747 // Obtain the height of the upper avoid area (such as the status bar). 1748 let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); 1749 WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height; 1750 1751 // Obtain the height of the navigation bar. 1752 let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); 1753 WindowUtils.navigationIndicatorHeight_px = navigationArea.bottomRect.height; 1754 1755 console.log(TAG, 'the width is ' + WindowUtils.windowWidth_px + ' ' + WindowUtils.windowHeight_px + ' ' + 1756 WindowUtils.topAvoidAreaHeight_px + ' ' + WindowUtils.navigationIndicatorHeight_px); 1757 1758 // Listen for changes in the window size, status bar height, and navigation bar height, and update accordingly. 1759 try { 1760 WindowUtils.window.on('windowSizeChange', (data) => { 1761 console.log(TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height); 1762 WindowUtils.windowWidth_px = data.width; 1763 WindowUtils.windowHeight_px = data.height; 1764 this.updateBreakpoint(data.width); 1765 AppStorage.setOrCreate('windowSizeChanged', Date.now()) 1766 }) 1767 1768 WindowUtils.window.on('avoidAreaChange', (data) => { 1769 if (data.type == window.AvoidAreaType.TYPE_SYSTEM) { 1770 let topRectHeight = data.area.topRect.height; 1771 console.log(TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight); 1772 WindowUtils.topAvoidAreaHeight_px = topRectHeight; 1773 } else if (data.type == window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) { 1774 let bottomRectHeight = data.area.bottomRect.height; 1775 console.log(TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight); 1776 WindowUtils.navigationIndicatorHeight_px = bottomRectHeight; 1777 } 1778 }) 1779 } catch (exception) { 1780 console.log('register failed ' + JSON.stringify(exception)); 1781 } 1782 1783 windowStage.loadContent('pages/Index', (err) => { 1784 if (err.code) { 1785 hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); 1786 return; 1787 } 1788 hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.'); 1789 }); 1790 } 1791 1792 updateBreakpoint(width: number) { 1793 let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160); 1794 let newBreakPoint: string = ''; 1795 if (windowWidthVp < 400) { 1796 newBreakPoint = 'xs'; 1797 } else if (windowWidthVp < 600) { 1798 newBreakPoint = 'sm'; 1799 } else if (windowWidthVp < 800) { 1800 newBreakPoint = 'md'; 1801 } else { 1802 newBreakPoint = 'lg'; 1803 } 1804 if (this.currentBreakPoint !== newBreakPoint) { 1805 this.currentBreakPoint = newBreakPoint; 1806 // Use the state variable to record the current breakpoint value. 1807 AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint); 1808 } 1809 } 1810 1811 onWindowStageDestroy(): void { 1812 // Main window is destroyed, release UI related resources 1813 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); 1814 } 1815 1816 onForeground(): void { 1817 // Ability has brought to foreground 1818 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground'); 1819 } 1820 1821 onBackground(): void { 1822 // Ability has back to background 1823 hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground'); 1824 } 1825} 1826``` 1827 1828 1829 1830## Using geometryTransition 1831 1832[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. 1833 1834To 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. 1835 1836This method is ideal for shared element transitions between two distinct objects. 1837 1838### Simple Use of geometryTransition 1839 1840Below is a simple example of using **geometryTransition** to implement shared element transition for two elements on the same page: 1841 1842```ts 1843import { curves } from '@kit.ArkUI'; 1844 1845@Entry 1846@Component 1847struct IfElseGeometryTransition { 1848 @State isShow: boolean = false; 1849 1850 build() { 1851 Stack({ alignContent: Alignment.Center }) { 1852 if (this.isShow) { 1853 Image($r('app.media.spring')) 1854 .autoResize(false) 1855 .clip(true) 1856 .width(200) 1857 .height(200) 1858 .borderRadius(100) 1859 .geometryTransition("picture") 1860 .transition(TransitionEffect.OPACITY) 1861 // If a new transition is triggered during the animation, ghosting occurs when id is not specified. 1862 // With id specified, the new spring image reuses the previous spring image node instead of creating a new node. Therefore, ghosting does not occur. 1863 // 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. 1864 .id('item1') 1865 } else { 1866 // geometryTransition is bound to a container. Therefore, a relative layout must be configured for the child components of the container. 1867 // The multiple levels of containers here are used to demonstrate passing of relative layout constraints. 1868 Column() { 1869 Column() { 1870 Image($r('app.media.sky')) 1871 .size({ width: '100%', height: '100%' }) 1872 } 1873 .size({ width: '100%', height: '100%' }) 1874 } 1875 .width(100) 1876 .height(100) 1877 // geometryTransition synchronizes rounded corner settings, but only for the bound component, which is the container in this example. 1878 // In other words, rounded corner settings of the container are synchronized, and those of the child components are not. 1879 .borderRadius(50) 1880 .clip(true) 1881 .geometryTransition("picture") 1882 // transition ensures that the component is not destroyed immediately when it exits. You can customize the transition effect. 1883 .transition(TransitionEffect.OPACITY) 1884 .position({ x: 40, y: 40 }) 1885 .id('item2') 1886 } 1887 } 1888 .onClick(() => { 1889 this.getUIContext()?.animateTo({ 1890 curve: curves.springMotion() 1891 }, () => { 1892 this.isShow = !this.isShow; 1893 }) 1894 }) 1895 .size({ width: '100%', height: '100%' }) 1896 } 1897} 1898``` 1899 1900 1901 1902### Combining geometryTransition with Modal Transition 1903 1904By 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. 1905 1906```ts 1907class PostData { 1908 avatar: Resource = $r('app.media.flower'); 1909 name: string = ''; 1910 message: string = ''; 1911 images: Resource[] = []; 1912} 1913 1914@Entry 1915@Component 1916struct Index { 1917 @State isPersonalPageShow: boolean = false; 1918 @State selectedIndex: number = 0; 1919 @State alphaValue: number = 1; 1920 1921 private allPostData: PostData[] = [ 1922 { avatar: $r('app.media.flower'), name: 'Alice', message: 'It's sunny.', 1923 images: [$r('app.media.spring'), $r('app.media.tree')] }, 1924 { avatar: $r('app.media.sky'), name: 'Bob', message: 'Hello World', 1925 images: [$r('app.media.island')] }, 1926 { avatar: $r('app.media.tree'), name: 'Carl', message: 'Everything grows.', 1927 images: [$r('app.media.flower'), $r('app.media.sky'), $r('app.media.spring')] }]; 1928 1929 private onAvatarClicked(index: number): void { 1930 this.selectedIndex = index; 1931 this.getUIContext()?.animateTo({ 1932 duration: 350, 1933 curve: Curve.Friction 1934 }, () => { 1935 this.isPersonalPageShow = !this.isPersonalPageShow; 1936 this.alphaValue = 0; 1937 }); 1938 } 1939 1940 private onPersonalPageBack(index: number): void { 1941 this.getUIContext()?.animateTo({ 1942 duration: 350, 1943 curve: Curve.Friction 1944 }, () => { 1945 this.isPersonalPageShow = !this.isPersonalPageShow; 1946 this.alphaValue = 1; 1947 }); 1948 } 1949 1950 @Builder 1951 PersonalPageBuilder(index: number) { 1952 Column({ space: 20 }) { 1953 Image(this.allPostData[index].avatar) 1954 .size({ width: 200, height: 200 }) 1955 .borderRadius(100) 1956 // Apply a shared element transition to the profile picture by its ID. 1957 .geometryTransition(index.toString()) 1958 .clip(true) 1959 .transition(TransitionEffect.opacity(0.99)) 1960 1961 Text(this.allPostData[index].name) 1962 .font({ size: 30, weight: 600 }) 1963 // Apply a transition effect to the text. 1964 .transition(TransitionEffect.asymmetric( 1965 TransitionEffect.OPACITY 1966 .combine(TransitionEffect.translate({ y: 100 })), 1967 TransitionEffect.OPACITY.animation({ duration: 0 }) 1968 )) 1969 1970 Text('Hello, this is' + this.allPostData[index].name) 1971 // Apply a transition effect to the text. 1972 .transition(TransitionEffect.asymmetric( 1973 TransitionEffect.OPACITY 1974 .combine(TransitionEffect.translate({ y: 100 })), 1975 TransitionEffect.OPACITY.animation({ duration: 0 }) 1976 )) 1977 } 1978 .padding({ top: 20 }) 1979 .size({ width: 360, height: 780 }) 1980 .backgroundColor(Color.White) 1981 .onClick(() => { 1982 this.onPersonalPageBack(index); 1983 }) 1984 .transition(TransitionEffect.asymmetric( 1985 TransitionEffect.opacity(0.99), 1986 TransitionEffect.OPACITY 1987 )) 1988 } 1989 1990 build() { 1991 Column({ space: 20 }) { 1992 ForEach(this.allPostData, (postData: PostData, index: number) => { 1993 Column() { 1994 Post({ data: postData, index: index, onAvatarClicked: (index: number) => { this.onAvatarClicked(index) } }) 1995 } 1996 .width('100%') 1997 }, (postData: PostData, index: number) => index.toString()) 1998 } 1999 .size({ width: '100%', height: '100%' }) 2000 .backgroundColor('#40808080') 2001 .bindContentCover(this.isPersonalPageShow, 2002 this.PersonalPageBuilder(this.selectedIndex), { modalTransition: ModalTransition.NONE }) 2003 .opacity(this.alphaValue) 2004 } 2005} 2006 2007@Component 2008export default struct Post { 2009 @Prop data: PostData; 2010 @Prop index: number; 2011 2012 @State expandImageSize: number = 100; 2013 @State avatarSize: number = 50; 2014 2015 private onAvatarClicked: (index: number) => void = (index: number) => { }; 2016 2017 build() { 2018 Column({ space: 20 }) { 2019 Row({ space: 10 }) { 2020 Image(this.data.avatar) 2021 .size({ width: this.avatarSize, height: this.avatarSize }) 2022 .borderRadius(this.avatarSize / 2) 2023 .clip(true) 2024 .onClick(() => { 2025 this.onAvatarClicked(this.index); 2026 }) 2027 // ID of the shared element transition bound to the profile picture. 2028 .geometryTransition(this.index.toString(), {follow:true}) 2029 .transition(TransitionEffect.OPACITY.animation({ duration: 350, curve: Curve.Friction })) 2030 2031 Text(this.data.name) 2032 } 2033 .justifyContent(FlexAlign.Start) 2034 2035 Text(this.data.message) 2036 2037 Row({ space: 15 }) { 2038 ForEach(this.data.images, (imageResource: Resource, index: number) => { 2039 Image(imageResource) 2040 .size({ width: 100, height: 100 }) 2041 }, (imageResource: Resource, index: number) => index.toString()) 2042 } 2043 } 2044 .backgroundColor(Color.White) 2045 .size({ width: '100%', height: 250 }) 2046 .alignItems(HorizontalAlign.Start) 2047 .padding({ left: 10, top: 10 }) 2048 } 2049} 2050``` 2051 2052After 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. 2053 2054 2055 2056<!--RP1--><!--RP1End--> 2057