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