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