目录

Shared Element Transition

Shared 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.

Let'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.

Frame-by-Frame Transition Effect Shared Element Transition

There are multiple methods for implementing the shared element transition. During real-world development, choose the method that best meets the requirements of your project.

Below is a comparison of the various methods available.

Implementation Method Description Use Case
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.
New Container with Cross-Container Component Migration By using NodeController, a component is migrated from one container to another. At the start of migration, translation and scaling are applied to the component based on the position and size information of both the source and target layouts, ensuring that the component aligns with the source layout at the beginning of migration and avoiding visual jumps. An animation is then added to reset the translation and scaling attributes, achieving a one-shot animation transition effect from the source layout to the target layout. Suitable for scenarios where creating new objects incurs high overhead, such as a video live streaming component transitioning to full screen upon tap.
Using geometryTransition for Shared Element Transition By leveraging system capabilities, the two components before and after the transition call the geometryTransition API to bind the same ID, and the transition logic is placed within an animateTo animation closure, so that the system automatically applies a one-shot animation transition effect to both components. The system adjusts the width, height, and position of the two bound components to the same values and switches their opacity to achieve the one-shot animation transition effect. Therefore, to achieve a smooth animation effect, it is necessary to ensure that no visual jumps occur when adding width and height animations to nodes bound with geometryTransition. This method is suitable for scenarios where the overhead of creating new nodes is low.

Implement Direct Transformation Without New Containers

This method does not create new containers. Instead, it triggers transition by adding or removing components on an existing container and pairs it with the property animation of components.

This 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 appear. The basic procedure is as follows:

  1. 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.

  2. 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.

Below is the complete sample code and effect.

import { common } from '@kit.AbilityKit';

class PostData {
  // Replace $r('app.media.flower') with the actual resource file.
  avatar: Resource = $r('app.media.flower');
  name: string = '';
  message: ResourceStr = '';
  images: Resource[] = [];
}

@Entry
@Component
struct Index {
  @State isExpand: boolean = false;
  @State @Watch('onItemClicked') selectedIndex: number = -1;
  private context = this.getUIContext().getHostContext() as common.UIAbilityContext;
  // Customize the image resource paths in the array as needed.
  private allPostData: PostData[] = [
    {
      // Replace $r('app.media.flower') with the actual resource file.
      avatar: $r('app.media.flower'),
      name: 'Alice',
      // Replace $r('app.string.shareTransition_text1') with the actual resource file. In this example, the value in the resource file is "Weather is fine."
      message: $r('app.string.shareTransition_text1'),
      // Replace $r('app.media.spring') with the actual resource file.
      // Replace $r('app.media.tall_tree') with the actual resource file.
      images: [$r('app.media.spring'), $r('app.media.tall_tree')]
    },
    {
      // Replace $r('app.media.sunset_sky') with the actual resource file.
      avatar: $r('app.media.sunset_sky'),
      name: 'Bob',
      // Replace $r('app.string.shareTransition_text2') with the actual resource file. In this example, the value in the resource file is "Hello, world."
      message: $r('app.string.shareTransition_text2'),
      // Replace $r('app.media.island') with the actual resource file.
      images: [$r('app.media.island')]
    },
    {
      // Replace $r('app.media.tall_tree') with the actual resource file.
      avatar: $r('app.media.tall_tree'),
      name: 'Carl',
      // Replace $r('app.string.shareTransition_text3') with the actual resource file. In this example, the value in the resource file is "All things grow."
      message: $r('app.string.shareTransition_text3'),
      // Replace $r('app.media.flower') with the actual resource file.
      // Replace $r('app.media.sunset_sky') with the actual resource file.
      // Replace $r('app.media.spring') with the actual resource file.
      images: [$r('app.media.flower'), $r('app.media.sunset_sky'), $r('app.media.spring')]
    }];

  private onItemClicked(): void {
    if (this.selectedIndex < 0) {
      return;
    }
    this.getUIContext()?.animateTo({
      duration: 350,
      curve: Curve.Friction
    }, () => {
      this.isExpand = !this.isExpand;
    });
  }

  build() {
    Column({ space: 20 }) {
      ForEach(this.allPostData, (postData: PostData, index: number) => {
        // When a post is clicked, other posts disappear from the tree.
        if (!this.isExpand || this.selectedIndex === index) {
          Column() {
            Post({ data: postData, selectedIndex: this.selectedIndex, index: index })
          }
          .width('100%')
          // Apply opacity and translate transition effects to the disappearing posts.
          .transition(TransitionEffect.OPACITY
            .combine(TransitionEffect.translate({ y: index < this.selectedIndex ? -250 : 250 }))
            .animation({ duration: 350, curve: Curve.Friction }))
        }
      }, (postData: PostData, index: number) => index.toString())
    }
    .size({ width: '100%', height: '100%' })
    .backgroundColor('#40808080')
  }
}

@Component
export default struct Post {
  @Link selectedIndex: number;
  @Prop data: PostData;
  @Prop index: number;
  @State itemHeight: number = 250;
  @State isExpand: boolean = false;
  @State expandImageSize: number = 100;
  @State avatarSize: number = 50;

  build() {
    Column({ space: 20 }) {
      Row({ space: 10 }) {
        Image(this.data.avatar)
          .size({ width: this.avatarSize, height: this.avatarSize })
          .borderRadius(this.avatarSize / 2)
          .clip(true)

        Text(this.data.name)
      }
      .justifyContent(FlexAlign.Start)

      Text(this.data.message)

      Row({ space: 15 }) {
        ForEach(this.data.images, (imageResource: Resource, index: number) => {
          Image(imageResource)
            .size({ width: this.expandImageSize, height: this.expandImageSize })
        }, (imageResource: Resource, index: number) => index.toString())
      }

      // Additional content for the expanded state
      if (this.isExpand) {
        Column() {
          // Replace $r('app.string.shareTransition_text4') with the actual resource file. In this example, the value in the resource file is "Comments."
          Text($r('app.string.shareTransition_text4'))
          // Apply enter/exit transition effects to the text in the comments area.
            .transition(TransitionEffect.OPACITY
              .animation({ duration: 350, curve: Curve.Friction }))
            .padding({ top: 10 })
        }
        .transition(TransitionEffect.asymmetric(
          TransitionEffect.opacity(0.99)
            .animation({ duration: 350, curve: Curve.Friction }),
          TransitionEffect.OPACITY.animation({ duration: 0 })
        ))
        .size({ width: '100%' })
      }
    }
    .backgroundColor(Color.White)
    .size({ width: '100%', height: this.itemHeight })
    .alignItems(HorizontalAlign.Start)
    .padding({ left: 10, top: 10 })
    .onClick(() => {
      this.selectedIndex = -1;
      this.selectedIndex = this.index;
      this.getUIContext()?.animateTo({
        duration: 350,
        curve: Curve.Friction
      }, () => {
        // Animate the width and height of the expanded post, and apply animations to the profile picture and image sizes.
        this.isExpand = !this.isExpand;
        this.itemHeight = this.isExpand ? 780 : 250;
        this.avatarSize = this.isExpand ? 75 : 50;
        this.expandImageSize = (this.isExpand && this.data.images.length > 0)
          ? (360 - (this.data.images.length + 1) * 15) / this.data.images.length : 100;
      })
    })
  }
}

Creating a Container and Migrating Components Across Containers

Use NodeContainer, a custom placeholder nodes, with NodeController 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) and sheet transitions (bindSheet).

[h2]Using with Stack

You can leverage the characteristic of Stack where components defined later are placed on top to control the stacking order of components after cross-node migration. Taking the scenario of expanding and collapsing a widget as an example, the implementation steps are as follows:

// Index.ets
import { createPostNode, getPostNode, PostNode } from './PostNode';
import { componentUtils, curves, UIContext } from '@kit.ArkUI';

@Entry
@Component
struct Index {
  // Create an animation class.
  private uiContext: UIContext = this.getUIContext();
  @State animationProperties: AnimationProperties = new AnimationProperties(this.uiContext);
  private listArray: Array<number> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

  build() {
    // Common parent component for widget collapsed and expanded states
    Stack() {
      List({ space: 20 }) {
        ForEach(this.listArray, (item: number) => {
          ListItem() {
            // Widget collapsed state
            PostItem({ index: item, animationProperties: this.animationProperties })
          }
        })
      }
      .clip(false)
      .alignListItem(ListItemAlign.Center)

      if (this.animationProperties.isExpandPageShow) {
        // Widget expanded state
        ExpandPage({ animationProperties: this.animationProperties })
      }
    }
    .key('rootStack')
    .enabled(this.animationProperties.isEnabled)
  }
}

@Component
struct PostItem {
  @Prop index: number
  @Link animationProperties: AnimationProperties;
  @State nodeController: PostNode | undefined = undefined;
  // Hide detailed content when the widget is collapsed.
  private showDetailContent: boolean = false;

  aboutToAppear(): void {
    this.nodeController = createPostNode(this.getUIContext(), this.index.toString(), this.showDetailContent);
    if (this.nodeController != undefined) {
      // Set a callback to trigger when the widget returns from expanded to collapsed state.
      this.nodeController.setCallback(this.resetNode.bind(this));
    }
  }

  resetNode() {
    this.nodeController = getPostNode(this.index.toString());
  }

  build() {
    Stack() {
      NodeContainer(this.nodeController)
    }
    .width('100%')
    .height(100)
    .key(this.index.toString())
    .onClick(() => {
      if (this.nodeController != undefined) {
        // The widget node is removed from the tree when collapsed.
        this.nodeController.onRemove();
      }
      // Trigger the animation for changing from the folded state to the collapsed state.
      this.animationProperties.expandAnimation(this.index);
    })
  }
}

@Component
struct ExpandPage {
  @Link animationProperties: AnimationProperties;
  @State nodeController: PostNode | undefined = undefined;
  // Show detailed content when the widget is expanded.
  private showDetailContent: boolean = true;

  aboutToAppear(): void {
    // Obtain the corresponding widget component by index.
    this.nodeController = getPostNode(this.animationProperties.curIndex.toString());
    // Update to show detailed content.
    this.nodeController?.update(this.animationProperties.curIndex.toString(), this.showDetailContent);
  }

  build() {
    Stack() {
      NodeContainer(this.nodeController)
    }
    .width('100%')
    .height(this.animationProperties.changedHeight ? '100%' : 100)
    .translate({ x: this.animationProperties.translateX, y: this.animationProperties.translateY })
    .position({ x: this.animationProperties.positionX, y: this.animationProperties.positionY })
    .onClick(() => {
      this.getUIContext()?.animateTo({
        curve: curves.springMotion(0.6, 0.9),
        onFinish: () => {
          if (this.nodeController != undefined) {
            // Execute the callback to obtain the widget component from the folded node.
            this.nodeController.callCallback();
            // The widget component of the currently expanded node is removed from the tree.
            this.nodeController.onRemove();
          }
          // The widget expands to the expanded state node and is removed from the tree.
          this.animationProperties.isExpandPageShow = false;
          this.animationProperties.isEnabled = true;
        }
      }, () => {
        // The widget returns from the expanded state to the collapsed state.
        this.animationProperties.isEnabled = false;
        this.animationProperties.translateX = 0;
        this.animationProperties.translateY = 0;
        this.animationProperties.changedHeight = false;
        // Update to hide detailed content.
        this.nodeController?.update(this.animationProperties.curIndex.toString(), false);
      })
    })
  }
}

class RectInfo {
  left: number = 0;
  top: number = 0;
  right: number = 0;
  bottom: number = 0;
  width: number = 0;
  height: number = 0;
}

// Encapsulated animation class.
@Observed
class AnimationProperties {
  public isExpandPageShow: boolean = false;
  // Control whether the component responds to click events.
  public isEnabled: boolean = true;
  // Index of the expanded widget.
  public curIndex: number = -1;
  public translateX: number = 0;
  public translateY: number = 0;
  public positionX: number = 0;
  public positionY: number = 0;
  public changedHeight: boolean = false;
  private calculatedTranslateX: number = 0;
  private calculatedTranslateY: number = 0;
  // Set the position of the widget relative to the parent component after it is expanded.
  private expandTranslateX: number = 0;
  private expandTranslateY: number = 0;
  private uiContext: UIContext;

  constructor(uiContext: UIContext) {
    this.uiContext = uiContext
  }

  public expandAnimation(index: number): void {
    // Record the index of the widget in the expanded state.
    if (index != undefined) {
      this.curIndex = index;
    }
    // Calculate the position of the collapsed widget relative to the parent component.
    this.calculateData(index.toString());
    // The widget in expanded state is added to the tree.
    this.isExpandPageShow = true;
    // Property animation for widget expansion.
    this.uiContext?.animateTo({
      curve: curves.springMotion(0.6, 0.9)
    }, () => {
      this.translateX = this.calculatedTranslateX;
      this.translateY = this.calculatedTranslateY;
      this.changedHeight = true;
    })
  }

  // 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.
  public calculateData(key: string): void {
    let clickedImageInfo = this.getRectInfoById(this.uiContext, key);
    let rootStackInfo = this.getRectInfoById(this.uiContext, 'rootStack');
    this.positionX = this.uiContext.px2vp(clickedImageInfo.left - rootStackInfo.left);
    this.positionY = this.uiContext.px2vp(clickedImageInfo.top - rootStackInfo.top);
    this.calculatedTranslateX = this.uiContext.px2vp(rootStackInfo.left - clickedImageInfo.left) +
      this.expandTranslateX;
    this.calculatedTranslateY = this.uiContext.px2vp(rootStackInfo.top - clickedImageInfo.top) + this.expandTranslateY;
  }

  // Obtain the position information of the component based on its ID.
  private getRectInfoById(context: UIContext, id: string): RectInfo {
    let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id);

    if (!componentInfo) {
      throw Error('object is empty');
    }

    let rstRect: RectInfo = new RectInfo();
    const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2;
    const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2;
    rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap;
    rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap;
    rstRect.right =
      componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap;
    rstRect.bottom =
      componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap;
    rstRect.width = rstRect.right - rstRect.left;
    rstRect.height = rstRect.bottom - rstRect.top;

    return {
      left: rstRect.left,
      right: rstRect.right,
      top: rstRect.top,
      bottom: rstRect.bottom,
      width: rstRect.width,
      height: rstRect.height
    }
  }
}
// PostNode.ets
// Cross-container migration
import { UIContext, curves, NodeController, BuilderNode, FrameNode } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

class Data {
  public item: string | null = null;
  public isExpand: boolean = false;
}
let context: undefined | common.UIAbilityContext = undefined;
@Builder
function postBuilder(data: Data) {
  // Place the cross-container migration component inside @Builder.
  Column() {
    Row() {
      Row()
        .backgroundColor(Color.Pink)
        .borderRadius(20)
        .width(80)
        .height(80)
      Column() {
        // Configure the resource whose name is 'shareTransition_text5' and value is a non-empty string in the resources\base\element\string.json file.
        Text((context as common.UIAbilityContext)?.resourceManager.getStringByNameSync('shareTransition_text5') + data.item)
          .fontSize(20)
        // Replace $r('app.string.shareTransition_text6') with the actual resource file. In this example, the value in the resource file is "Shared element transition."
        Text($r('app.string.shareTransition_text6'))
          .fontSize(12)
          .fontColor(0x909399)
      }
      .alignItems(HorizontalAlign.Start)
      .justifyContent(FlexAlign.SpaceAround)
      .margin({ left: 10 })
      .height(80)
    }
    .width('90%')
    .height(100)

    // Display detailed content in expanded state.
    if (data.isExpand) {
      Row() {
        // Replace $r('app.string.shareTransition_text7') with the actual resource file. In this example, the value in the resource file is "Expanded."
        Text($r('app.string.shareTransition_text7'))
          .fontSize(28)
          .fontColor(0x909399)
          .textAlign(TextAlign.Center)
          .transition(TransitionEffect.OPACITY.animation({ curve: curves.springMotion(0.6, 0.9) }))
      }
      .width('90%')
      .justifyContent(FlexAlign.Center)
    }
  }
  .width('90%')
  .height('100%')
  .alignItems(HorizontalAlign.Center)
  .borderRadius(10)
  .margin({ top: 15 })
  .backgroundColor(Color.White)
  .shadow({
    radius: 20,
    color: 0x909399,
    offsetX: 20,
    offsetY: 10
  })
}

class InternalValue {
  public flag: boolean = false;
};

export class PostNode extends NodeController {
  private node: BuilderNode<Data[]> | null = null;
  private isRemove: InternalValue = new InternalValue();
  private callback: Function | undefined = undefined;
  private data: Data | null = null;

  makeNode(uiContext: UIContext): FrameNode | null {
    if (this.isRemove.flag === true) {
      return null;
    }
    if (this.node != null) {
      return this.node.getFrameNode();
    }

    return null;
  }

  init(uiContext: UIContext, id: string, isExpand: boolean) {
    if (this.node != null) {
      return;
    }
    // Create a node, during which the UIContext should be passed.
    this.node = new BuilderNode(uiContext);
    context = uiContext.getHostContext() as common.UIAbilityContext;
    // Create an offline component.
    this.data = { item: id, isExpand: isExpand };
    this.node.build(wrapBuilder<Data[]>(postBuilder), this.data);
  }

  update(id: string, isExpand: boolean) {
    if (this.node !== null) {
      // Call update to perform an update.
      this.data = { item: id, isExpand: isExpand };
      this.node.update(this.data);
    }
  }

  setCallback(callback: Function | undefined) {
    this.callback = callback;
  }

  callCallback() {
    if (this.callback != undefined) {
      this.callback();
    }
  }

  onRemove() {
    this.isRemove.flag = true;
    // Trigger rebuild when the component is migrated out of the node.
    this.rebuild();
    this.isRemove.flag = false;
  }
}

let gNodeMap: Map<string, PostNode | undefined> = new Map();

export const createPostNode =
  (uiContext: UIContext, id: string, isExpand: boolean): PostNode | undefined => {
    let node = new PostNode();
    node.init(uiContext, id, isExpand);
    gNodeMap.set(id, node);
    return node;
  }

export const getPostNode = (id: string): PostNode | undefined => {
  if (!gNodeMap.has(id)) {
    return undefined;
  }
  return gNodeMap.get(id);
}

export const deleteNode = (id: string) => {
  gNodeMap.delete(id);
}

[h2]Using with Navigation

You can use the customNavContentTransition (see Example 3) capability of Navigation to implement shared element transition, during which, the component is migrated from the disappearing page to the appearing page.

The following is the procedure for implementing the expanding and collapsing of a thumbnail:

├──entry/src/main/ets                 // Code directory
│  ├──CustomTransition
│  │  ├──AnimationProperties.ets      // Encapsulation of shared element transition animation
│  │  └──CustomNavigationUtils.ets    // Custom transition animation configuration for Navigation
│  ├──entryability
│  │  └──EntryAbility.ets             // Entry point class
│  ├──NodeContainer
│  │  └──CustomComponent.ets          // Custom placeholder node
│  ├──pages
│  │  ├──Index.ets                    // Navigation page
│  │  ├──PageOne.ets                  // Thumbnail page
│  │  └──PageTwo.ets                  // Full-screen page
│  └──utils
│     ├──ComponentAttrUtils.ets       // Component position acquisition
│     └──WindowUtils.ets              // Window information
└──entry/src/main/resources           // Resource files
// Index.ets
import { AnimateCallback, CustomTransition } from '../../../CustomTransition/CustomNavigationUtils';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG: string = 'Index';
const DOMAIN = 0xF811;

@Entry
@Component
struct Index {
  private pageInfos: NavPathStack = new NavPathStack();
  // Allow custom transition for specific pages by name.
  private allowedCustomTransitionFromPageName: string[] = ['PageOne'];
  private allowedCustomTransitionToPageName: string[] = ['PageTwo'];

  aboutToAppear(): void {
    this.pageInfos.pushPath({ name: 'PageOne' });
  }

  private isCustomTransitionEnabled(fromName: string, toName: string): boolean {
    // Both clicks and returns require custom transitions, so they need to be judged separately.
    if ((this.allowedCustomTransitionFromPageName.includes(fromName) &&
      this.allowedCustomTransitionToPageName.includes(toName)) ||
      (this.allowedCustomTransitionFromPageName.includes(toName) &&
      this.allowedCustomTransitionToPageName.includes(fromName))) {
      return true;
    }
    return false;
  }

  build() {
    Navigation(this.pageInfos)
      .hideNavBar(true)
      .customNavContentTransition((from: NavContentInfo, to: NavContentInfo, operation: NavigationOperation) => {
        if ((!from || !to) || (!from.name || !to.name)) {
          return undefined;
        }

        // Control custom transition routes by the names of 'from' and 'to'.
        if (!this.isCustomTransitionEnabled(from.name, to.name)) {
          return undefined;
        }

        // Check whether the transition pages have registered animations to decide whether to perform a custom transition.
        let fromParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(from.index);
        let toParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(to.index);
        if (!fromParam.animation || !toParam.animation) {
          return undefined;
        }

        // After all judgments are made, construct customAnimation for the system side to call and execute the custom transition animation.
        let customAnimation: NavigationAnimatedTransition = {
          onTransitionEnd: (isSuccess: boolean) => {
            hilog.info(DOMAIN, 'current transition result is', 'isSuccess: %s', isSuccess.toString());
          },
          timeout: 2000,
          transition: (transitionProxy: NavigationTransitionProxy) => {
            hilog.info(DOMAIN, TAG, 'trigger transition callback');
            if (fromParam.animation) {
              fromParam.animation(operation === NavigationOperation.PUSH, true, transitionProxy);
            }
            if (toParam.animation) {
              toParam.animation(operation === NavigationOperation.PUSH, false, transitionProxy);
            }
          }
        };
        return customAnimation;
      })
  }
}
// PageOne.ets
import { CustomTransition } from '../../../CustomTransition/CustomNavigationUtils';
import { MyNodeController, createMyNode, getMyNode } from '../../../NodeContainer/CustomComponent';
import { ComponentAttrUtils, RectInfoInPx } from '../../../utils/ComponentAttrUtils';
import { WindowUtils } from '../../../utils/WindowUtils';

@Builder
export function PageOneBuilder() {
  PageOne();
}

@Component
export struct PageOne {
  private pageInfos: NavPathStack = new NavPathStack();
  private pageId: number = -1;
  @State myNodeController: MyNodeController | undefined = new MyNodeController(false);

  aboutToAppear(): void {
    let node = getMyNode();
    if (node === undefined) {
      // Create a custom node.
      createMyNode(this.getUIContext());
    }
    this.myNodeController = getMyNode();
  }

  private doFinishTransition(): void {
    // Migrate the node back from PageTwo to PageOne when the transition on PageTwo ends.
    this.myNodeController = getMyNode();
  }

  private registerCustomTransition(): void {
    // Register the custom animation protocol.
    CustomTransition.getInstance().registerNavParam(this.pageId,
      (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => {
      }, 500);
  }

  private onCardClicked(): void {
    let cardItemInfo: RectInfoInPx =
      ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), 'card');
    let param: Record<string, Object> = {};
    param['cardItemInfo'] = cardItemInfo;
    param['doDefaultTransition'] = (myController: MyNodeController) => {
      this.doFinishTransition();
    };
    this.pageInfos.pushPath({ name: 'PageTwo', param: param });
    // The custom node is removed from the tree of PageOne.
    if (this.myNodeController != undefined) {
      (this.myNodeController as MyNodeController).onRemove();
    }
  }

  build() {
    NavDestination() {
      Stack() {
        Column({ space: 20 }) {
          Row({ space: 10 }) {
            // Replace $r('app.media.avatar') with the actual resource file.
            Image($r('app.media.avatar'))
              .size({ width: 50, height: 50 })
              .borderRadius(25)
              .clip(true)

            Text('Alice')
          }
          .justifyContent(FlexAlign.Start)

          // Replace $r('app.string.shareTransition_text2') with the actual resource file. In this example, the value in the resource file is "Hello World."
          Text($r('app.string.shareTransition_text2'))

          NodeContainer(this.myNodeController)
            .size({ width: 320, height: 250 })
            .onClick(() => {
              this.onCardClicked();
            })
        }
        .alignItems(HorizontalAlign.Start)
        .margin(30)
      }
    }
    .onReady((context: NavDestinationContext) => {
      this.pageInfos = context.pathStack;
      this.pageId = this.pageInfos.getAllPathName().length - 1;
      this.registerCustomTransition();
    })
    .onDisAppear(() => {
      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
      // The custom node is removed from the tree of PageOne.
      if (this.myNodeController != undefined) {
        (this.myNodeController as MyNodeController).onRemove();
      }
    })
  }
}
// PageTwo.ets
import { CustomTransition } from '../../../CustomTransition/CustomNavigationUtils';
import { AnimationProperties } from '../../../CustomTransition/AnimationProperties';
import { RectInfoInPx } from '../../../utils/ComponentAttrUtils';
import { getMyNode, MyNodeController } from '../../../NodeContainer/CustomComponent';

@Builder
export function PageTwoBuilder() {
  PageTwo();
}

@Component
export struct PageTwo {
  @State pageInfos: NavPathStack = new NavPathStack();
  @State animationProperties: AnimationProperties = new AnimationProperties(this.getUIContext());
  @State myNodeController: MyNodeController | undefined = new MyNodeController(false);
  private pageId: number = -1;
  private shouldDoDefaultTransition: boolean = false;
  private prePageDoFinishTransition: () => void = () => {};
  private cardItemInfo: RectInfoInPx = new RectInfoInPx();
  @StorageProp('windowSizeChanged') @Watch('unRegisterNavParam') windowSizeChangedTime: number = 0;
  @StorageProp('onConfigurationUpdate') @Watch('unRegisterNavParam') onConfigurationUpdateTime: number = 0;

  aboutToAppear(): void {
    // Migrate the custom node to the current page.
    this.myNodeController = getMyNode();
  }

  private unRegisterNavParam(): void {
    this.shouldDoDefaultTransition = true;
  }

  private onBackPressed(): boolean {
    if (this.shouldDoDefaultTransition) {
      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
      this.pageInfos.pop();
      this.prePageDoFinishTransition();
      this.shouldDoDefaultTransition = false;
      return true;
    }
    this.pageInfos.pop();
    return true;
  }

  build() {
    NavDestination() {
      // Set alignContent to TopStart for Stack; otherwise, during height changes, both the snapshot and content will be repositioned with the height relayout.
      Stack({ alignContent: Alignment.TopStart }) {
        Stack({ alignContent: Alignment.TopStart }) {
          Column({ space: 20 }) {
            NodeContainer(this.myNodeController);
            if (this.animationProperties.showDetailContent) {
              // Replace $r('app.string.shareTransition_text8') with the actual resource file. In this example, the value in the resource file is "Expanded."
              Text($r('app.string.shareTransition_text8'))
                .fontSize(20)
                .transition(TransitionEffect.OPACITY)
                .margin(30)
            }
          }
          .alignItems(HorizontalAlign.Start)
        }
        .position({ y: this.animationProperties.positionValue });
      }
      .scale({ x: this.animationProperties.scaleValue, y: this.animationProperties.scaleValue })
      .translate({ x: this.animationProperties.translateX, y: this.animationProperties.translateY })
      .width(this.animationProperties.clipWidth)
      .height(this.animationProperties.clipHeight)
      .borderRadius(this.animationProperties.radius)
      // Use expandSafeArea to create an immersive effect for Stack, expanding it upwards to the status bar and downwards to the navigation bar.
      .expandSafeArea([SafeAreaType.SYSTEM])
      // Clip the height.
      .clip(true)
    }
    .backgroundColor(this.animationProperties.navDestinationBgColor)
    .hideTitleBar(true)
    .onReady((context: NavDestinationContext) => {
      this.pageInfos = context.pathStack;
      this.pageId = this.pageInfos.getAllPathName().length - 1;
      let param = context.pathInfo?.param as Record<string, Object>;
      this.prePageDoFinishTransition = param['doDefaultTransition'] as () => void;
      this.cardItemInfo = param['cardItemInfo'] as RectInfoInPx;
      CustomTransition.getInstance().registerNavParam(this.pageId,
        (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => {
          this.animationProperties.doAnimation(
            this.cardItemInfo, isPush, isExit, transitionProxy, 0,
            this.prePageDoFinishTransition, this.myNodeController);
        }, 500);
    })
    .onBackPressed(() => {
      return this.onBackPressed();
    })
    .onDisAppear(() => {
      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
    })
  }
}
// CustomNavigationUtils.ets
// Configure custom transition animations for Navigation.
export interface AnimateCallback {
  animation: ((isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void | undefined)
    | undefined;
  timeout: (number | undefined) | undefined;
}

const customTransitionMap: Map<number, AnimateCallback> = new Map();

export class CustomTransition {
  private constructor() {
  };

  static delegate = new CustomTransition();

  static getInstance() {
    return CustomTransition.delegate;
  }

  // Register the animation callback for a page, where name is the identifier for the page's animation callback.
  // animationCallback indicates the animation content to be executed, and timeout indicates the timeout for ending the transition.
  registerNavParam(
    name: number,
    animationCallback: (operation: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void,
    timeout: number): void {
    if (customTransitionMap.has(name)) {
      let param = customTransitionMap.get(name);
      if (param != undefined) {
        param.animation = animationCallback;
        param.timeout = timeout;
        return;
      }
    }
    let params: AnimateCallback = { timeout: timeout, animation: animationCallback };
    customTransitionMap.set(name, params);
  }

  unRegisterNavParam(name: number): void {
    customTransitionMap.delete(name);
  }

  getAnimateParam(name: number): AnimateCallback {
    let result: AnimateCallback = {
      animation: customTransitionMap.get(name)?.animation,
      timeout: customTransitionMap.get(name)?.timeout,
    };
    return result;
  }
}
// Configure {"routerMap": "$profile:route_map"} in the project configuration file module.json5.
// route_map.json
{
  "routerMap": [
    {
      "name": "PageOne",
      "pageSourceFile": "src/main/ets/pages/PageOne.ets",
      "buildFunction": "PageOneBuilder"
    },
    {
      "name": "PageTwo",
      "pageSourceFile": "src/main/ets/pages/PageTwo.ets",
      "buildFunction": "PageTwoBuilder"
    }
  ]
}
// AnimationProperties.ets
// Encapsulation of shared element transition animation
import { curves, UIContext } from '@kit.ArkUI';
import { RectInfoInPx } from '../utils/ComponentAttrUtils';
import { WindowUtils } from '../utils/WindowUtils';
import { MyNodeController } from '../NodeContainer/CustomComponent';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG: string = 'AnimationProperties';
const DOMAIN = 0xF811;
const DEVICE_BORDER_RADIUS: number = 34;

// Encapsulate the custom shared element transition animation, which can be directly reused by other APIs to reduce workload.
@Observed
export class AnimationProperties {
  public navDestinationBgColor: ResourceColor = Color.Transparent;
  public translateX: number = 0;
  public translateY: number = 0;
  public scaleValue: number = 1;
  public clipWidth: Dimension = 0;
  public clipHeight: Dimension = 0;
  public radius: number = 0;
  public positionValue: number = 0;
  public showDetailContent: boolean = false;
  private uiContext: UIContext;

  constructor(uiContext: UIContext) {
    this.uiContext = uiContext;
  }

  public doAnimation(cardItemInfoPx: RectInfoInPx, isPush: boolean, isExit: boolean,
    transitionProxy: NavigationTransitionProxy, extraTranslateValue: number,
    prePageOnFinish: (index: MyNodeController) => void, myNodeController: MyNodeController | undefined): void {
    // Calculate the ratio of the widget's width and height to the window's width and height.
    let widthScaleRatio = cardItemInfoPx.width / WindowUtils.windowWidthPx;
    let heightScaleRatio = cardItemInfoPx.height / WindowUtils.windowHeightPx;
    let isUseWidthScale = widthScaleRatio > heightScaleRatio;
    let initScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio;

    let initTranslateX: number = 0;
    let initTranslateY: number = 0;
    let initClipWidth: Dimension = 0;
    let initClipHeight: Dimension = 0;
    // Ensure that the widget on PageTwo expands to the status bar at the top.
    let initPositionValue: number = -this.uiContext.px2vp(WindowUtils.topAvoidAreaHeightPx + extraTranslateValue);

    if (isUseWidthScale) {
      initTranslateX = this.uiContext.px2vp(cardItemInfoPx.left -
        (WindowUtils.windowWidthPx - cardItemInfoPx.width) / 2);
      initClipWidth = '100%';
      initClipHeight = this.uiContext.px2vp((cardItemInfoPx.height) / initScale);
      initTranslateY = this.uiContext.px2vp(cardItemInfoPx.top - ((this.uiContext.vp2px(initClipHeight) -
        this.uiContext.vp2px(initClipHeight) * initScale) / 2));
    } else {
      initTranslateY = this.uiContext.px2vp(cardItemInfoPx.top -
        (WindowUtils.windowHeightPx - cardItemInfoPx.height) / 2);
      initClipHeight = '100%';
      initClipWidth = this.uiContext.px2vp((cardItemInfoPx.width) / initScale);
      initTranslateX = this.uiContext.px2vp(cardItemInfoPx.left -
        (WindowUtils.windowWidthPx / 2 - cardItemInfoPx.width / 2));
    }

    // 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.
    hilog.info(DOMAIN, TAG, 'initScale: ' + initScale + ' initTranslateX ' + initTranslateX +
      ' initTranslateY ' + initTranslateY + ' initClipWidth ' + initClipWidth +
      ' initClipHeight ' + initClipHeight + ' initPositionValue ' + initPositionValue);

    // Transition to the new page
    if (isPush && !isExit) {
      this.scaleValue = initScale;
      this.translateX = initTranslateX;
      this.clipWidth = initClipWidth;
      this.clipHeight = initClipHeight;
      this.translateY = initTranslateY;
      this.positionValue = initPositionValue;

      this.uiContext?.animateTo({
        curve: curves.interpolatingSpring(0, 1, 328, 36),
        onFinish: () => {
          if (transitionProxy) {
            transitionProxy.finishTransition();
          }
        }
      }, () => {
        this.scaleValue = 1.0;
        this.translateX = 0;
        this.translateY = 0;
        this.clipWidth = '100%';
        this.clipHeight = '100%';
        // The page corner radius matches the system corner radius.
        this.radius = DEVICE_BORDER_RADIUS;
        this.showDetailContent = true;
      })

      this.uiContext?.animateTo({
        duration: 100,
        curve: Curve.Sharp,
      }, () => {
        // The page background gradually changes from transparent to the set color.
        this.navDestinationBgColor = '#00ffffff';
      })

      // Return to the previous page.
    } else if (!isPush && isExit) {

      this.uiContext?.animateTo({
        duration: 350,
        curve: Curve.EaseInOut,
        onFinish: () => {
          if (transitionProxy) {
            transitionProxy.finishTransition();
          }
          prePageOnFinish(myNodeController);
          // The custom node is removed from the tree of PageTwo.
          if (myNodeController != undefined) {
            (myNodeController as MyNodeController).onRemove();
          }
        }
      }, () => {
        this.scaleValue = initScale;
        this.translateX = initTranslateX;
        this.translateY = initTranslateY;
        this.radius = 0;
        this.clipWidth = initClipWidth;
        this.clipHeight = initClipHeight;
        this.showDetailContent = false;
      })

      this.uiContext?.animateTo({
        duration: 200,
        delay: 150,
        curve: Curve.Friction,
      }, () => {
        this.navDestinationBgColor = Color.Transparent;
      })
    }
  }
}
// ComponentAttrUtils.ets
// Obtain the position of the component relative to the window.
import { componentUtils, UIContext } from '@kit.ArkUI';
import { JSON } from '@kit.ArkTS';

export class ComponentAttrUtils {
  // Obtain the position information of the component based on its ID.
  public static getRectInfoById(context: UIContext, id: string): RectInfoInPx {
    if (!context || !id) {
      throw Error('object is empty');
    }
    let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id);

    if (!componentInfo) {
      throw Error('object is empty');
    }

    let rstRect: RectInfoInPx = new RectInfoInPx();
    const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2;
    const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2;
    rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap;
    rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap;
    rstRect.right =
      componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap;
    rstRect.bottom =
      componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap;
    rstRect.width = rstRect.right - rstRect.left;
    rstRect.height = rstRect.bottom - rstRect.top;
    return {
      left: rstRect.left,
      right: rstRect.right,
      top: rstRect.top,
      bottom: rstRect.bottom,
      width: rstRect.width,
      height: rstRect.height
    }
  }
}

export class RectInfoInPx {
  public left: number = 0;
  public top: number = 0;
  public right: number = 0;
  public bottom: number = 0;
  public width: number = 0;
  public height: number = 0;
}

export class RectJson {
  public $rect: Array<number> = [];
}
// WindowUtils.ets
// Window information
import { window } from '@kit.ArkUI';

export class WindowUtils {
  public static window: window.Window;
  public static windowWidthPx: number;
  public static windowHeightPx: number;
  public static topAvoidAreaHeightPx: number;
  public static navigationIndicatorHeightPx: number;
}
// EntryAbility.ets
// Add capture of window width and height in onWindowStageCreate at the application entry.

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { WindowUtils } from '../utils/WindowUtils';
import { display, window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN = 0x0000;
const TAG: string = 'EntryAbility';

export default class EntryAbility extends UIAbility {
  private currentBreakPoint: string = '';

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onCreate');
  }

  onDestroy(): void {
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onDestroy');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // Main window is created, set main page for this ability
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageCreate');
    // ...
    // Obtain the window width and height.
    WindowUtils.window = windowStage.getMainWindowSync();
    WindowUtils.windowWidthPx = WindowUtils.window.getWindowProperties().windowRect.width;
    WindowUtils.windowHeightPx = WindowUtils.window.getWindowProperties().windowRect.height;

    this.updateBreakpoint(WindowUtils.windowWidthPx);

    // Obtain the height of the upper avoid area (such as the status bar).
    let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
    WindowUtils.topAvoidAreaHeightPx = avoidArea.topRect.height;

    // Obtain the height of the navigation bar.
    let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
    WindowUtils.navigationIndicatorHeightPx = navigationArea.bottomRect.height;

    hilog.info(DOMAIN, TAG, 'the width is ' + WindowUtils.windowWidthPx + '  ' + WindowUtils.windowHeightPx + '  ' +
    WindowUtils.topAvoidAreaHeightPx + '  ' + WindowUtils.navigationIndicatorHeightPx);

    // Listen for changes in the window size, status bar height, and navigation bar height, and update accordingly.
    try {
      WindowUtils.window.on('windowSizeChange', (data) => {
        hilog.info(DOMAIN, TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height);
        WindowUtils.windowWidthPx = data.width;
        WindowUtils.windowHeightPx = data.height;
        this.updateBreakpoint(data.width);
        AppStorage.setOrCreate('windowSizeChanged', Date.now());
      })

      WindowUtils.window.on('avoidAreaChange', (data) => {
        if (data.type === window.AvoidAreaType.TYPE_SYSTEM) {
          let topRectHeight = data.area.topRect.height;
          hilog.info(DOMAIN, TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight);
          WindowUtils.topAvoidAreaHeightPx = topRectHeight;
        } else if (data.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
          let bottomRectHeight = data.area.bottomRect.height;
          hilog.info(DOMAIN, TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight);
          WindowUtils.navigationIndicatorHeightPx = bottomRectHeight;
        }
      })
    } catch (exception) {
      hilog.error(DOMAIN, TAG, `register failed. code: ${exception.code}, message: ${exception.message}`);
    }

    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
        return;
      }
      hilog.info(DOMAIN, TAG, 'Succeeded in loading the content.');
    });
  }

  updateBreakpoint(width: number) {
    let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160);
    let newBreakPoint: string = '';
    if (windowWidthVp < 400) {
      newBreakPoint = 'xs';
    } else if (windowWidthVp < 600) {
      newBreakPoint = 'sm';
    } else if (windowWidthVp < 800) {
      newBreakPoint = 'md';
    } else {
      newBreakPoint = 'lg';
    }
    if (this.currentBreakPoint !== newBreakPoint) {
      this.currentBreakPoint = newBreakPoint;
      // Use the state variable to record the current breakpoint value.
      AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint);
    }
  }
  onWindowStageDestroy(): void {
    // Main window is destroyed, release UI related resources
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageDestroy');
  }

  onForeground(): void {
    // Ability has brought to foreground
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onForeground');
  }

  onBackground(): void {
    // Ability has back to background
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground');
  }
}
// CustomComponent.ets
// Custom placeholder node with cross-container migration capability
import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI';

@Builder
function cardBuilder() {
  // Replace $r('app.media.card') with the actual resource file.
  Image($r('app.media.card'))
    .width('100%')
    .id('card')
}

export class MyNodeController extends NodeController {
  private cardNode: BuilderNode<[]> | null = null;
  private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(cardBuilder);
  private needCreate: boolean = false;
  private isRemove: boolean = false;

  constructor(create: boolean) {
    super();
    this.needCreate = create;
  }

  makeNode(uiContext: UIContext): FrameNode | null {
    if (this.isRemove === true) {
      return null;
    }
    if (this.needCreate && this.cardNode === null) {
      this.cardNode = new BuilderNode(uiContext);
      this.cardNode.build(this.wrapBuilder);
    }
    if (this.cardNode === null) {
      return null;
    }
    return this.cardNode!.getFrameNode()!;
  }

  getNode(): BuilderNode<[]> | null {
    return this.cardNode;
  }

  setNode(node: BuilderNode<[]> | null) {
    this.cardNode = node;
    this.rebuild();
  }

  onRemove() {
    this.isRemove = true;
    this.rebuild();
    this.isRemove = false;
  }

  init(uiContext: UIContext) {
    this.cardNode = new BuilderNode(uiContext);
    this.cardNode.build(this.wrapBuilder);
  }
}

let myNode: MyNodeController | undefined;

export const createMyNode =
  (uiContext: UIContext) => {
    myNode = new MyNodeController(false);
    myNode.init(uiContext);
  }

export const getMyNode = (): MyNodeController | undefined => {
  return myNode;
}

[h2]Using with BindSheet

To achieve a seamless transition to a sheet (bindSheet) with a shared element animation from the initial screen, set the mode in 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 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.

To implement a shared element transition to a sheet when an image is clicked:

├──entry/src/main/ets                 // Code directory
│  ├──entryability
│  │  └──EntryAbility.ets             // Entry point class
│  ├──NodeContainer
│  │  └──CustomComponent.ets          // Custom placeholder node
│  ├──pages
│  │  └──Index.ets                    // Home page for the shared element transition
│  └──utils
│     ├──ComponentAttrUtils.ets       // Component position acquisition
│     └──WindowUtils.ets              // Window information
└──entry/src/main/resources           // Resource files
// Index.ets
import { MyNodeController, createMyNode, getMyNode } from '../NodeContainer/CustomComponent';
import { ComponentAttrUtils, RectInfoInPx } from '../utils/ComponentAttrUtils';
import { WindowUtils } from '../utils/WindowUtils';
import { inspector } from '@kit.ArkUI'

class AnimationInfo {
  scale: number = 0;
  translateX: number = 0;
  translateY: number = 0;
  clipWidth: Dimension = 0;
  clipHeight: Dimension = 0;
}

@Entry
@Component
struct Index {
  @State isShowSheet: boolean = false;
  @State isShowImage: boolean = false;
  @State isShowOverlay: boolean = false;
  @State isAnimating: boolean = false;
  @State isEnabled: boolean = true;

  @State scaleValue: number = 0;
  @State translateX: number = 0;
  @State translateY: number = 0;
  @State clipWidth: Dimension = 0;
  @State clipHeight: Dimension = 0;
  @State radius: number = 0;
  // Original image opacity
  @State opacityDegree: number = 1;

  // Capture the original position information of the photo.
  private originInfo: AnimationInfo = new AnimationInfo;
  // Capture the photo's position information on the sheet.
  private targetInfo: AnimationInfo = new AnimationInfo;
  // Height of the sheet.
  private bindSheetHeight: number = 450;
  // Image corner radius on the sheet.
  private sheetRadius: number = 20;

  // Set a layout listener for the image on the sheet.
  listener:inspector.ComponentObserver = this.getUIContext().getUIInspector().createComponentObserver('target');
  aboutToAppear(): void {
    // Set a callback for when the layout of the image on the sheet is complete.
    let onLayoutComplete:()=>void=():void=>{
      // When the target image layout is complete, capture the layout information.
      this.targetInfo = this.calculateData('target');
      // Trigger the shared element transition animation only when the sheet is properly laid out and there is no animation currently running.
      if (this.targetInfo.scale != 0 && this.targetInfo.clipWidth != 0 && this.targetInfo.clipHeight != 0 && !this.isAnimating) {
        this.isAnimating = true;
        // Property animation for shared element transition animation of the modal
        this.getUIContext()?.animateTo({
          duration: 1000,
          curve: Curve.Friction,
          onFinish: () => {
            // The custom node on the modal transition page (overlay) is removed from the tree.
            this.isShowOverlay = false;
            // The custom node on the sheet is added to the tree, completing the node migration.
            this.isShowImage = true;
          }
        }, () => {
          this.scaleValue = this.targetInfo.scale;
          this.translateX = this.targetInfo.translateX;
          this.clipWidth = this.targetInfo.clipWidth;
          this.clipHeight = this.targetInfo.clipHeight;
          // Adjust for height differences caused by sheet height and scaling.
          this.translateY = this.targetInfo.translateY +
            (this.getUIContext().px2vp(WindowUtils.windowHeightPx) - this.bindSheetHeight
              - this.getUIContext().px2vp(WindowUtils.navigationIndicatorHeightPx) - this.getUIContext().px2vp(WindowUtils.topAvoidAreaHeightPx));
          // Adjust for corner radius differences caused by scaling.
          this.radius = this.sheetRadius / this.scaleValue
        })
        // Animate the original image from transparent to fully visible.
        this.getUIContext()?.animateTo({
          duration: 2000,
          curve: Curve.Friction,
        }, () => {
          this.opacityDegree = 1;
        })
      }
    }
    // Enable the layout listener.
    this.listener.on('layout', onLayoutComplete)
  }

  // Obtain the attributes of the component with the corresponding ID relative to the upper left corner of the window.
  calculateData(id: string): AnimationInfo {
    let itemInfo: RectInfoInPx =
      ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), id);
    // Calculate the ratio of the image's width and height to the window's width and height.
    let widthScaleRatio = itemInfo.width / WindowUtils.windowWidthPx;
    let heightScaleRatio = itemInfo.height / WindowUtils.windowHeightPx;
    let isUseWidthScale = widthScaleRatio > heightScaleRatio;
    let itemScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio;
    let itemTranslateX: number = 0;
    let itemClipWidth: Dimension = 0;
    let itemClipHeight: Dimension = 0;
    let itemTranslateY: number = 0;

    if (isUseWidthScale) {
      itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidthPx - itemInfo.width) / 2);
      itemClipWidth = '100%';
      itemClipHeight = this.getUIContext().px2vp((itemInfo.height) / itemScale);
      itemTranslateY = this.getUIContext().px2vp(itemInfo.top - ((this.getUIContext().vp2px(itemClipHeight) - this.getUIContext().vp2px(itemClipHeight) * itemScale) / 2));
    } else {
      itemTranslateY = this.getUIContext().px2vp(itemInfo.top - (WindowUtils.windowHeightPx - itemInfo.height) / 2);
      itemClipHeight = '100%';
      itemClipWidth = this.getUIContext().px2vp((itemInfo.width) / itemScale);
      itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidthPx / 2 - itemInfo.width / 2));
    }

    return {
      scale: itemScale,
      translateX: itemTranslateX ,
      translateY: itemTranslateY,
      clipWidth: itemClipWidth,
      clipHeight: itemClipHeight,
    }
  }

  // Photo page.
  build() {
    Column() {
      Text('Photo')
        .textAlign(TextAlign.Start)
        .width('100%')
        .fontSize(30)
        .padding(20)
      // Customize the image resource path as needed.
      Image($r("app.media.flower"))
        .opacity(this.opacityDegree)
        .width('90%')
        .id('origin')// Mount the sheet page.
        .enabled(this.isEnabled)
        .onClick(() => {
          // Obtain the position information of the original image, and move and scale the image on the modal page to this position.
          this.originInfo = this.calculateData('origin');
          this.scaleValue = this.originInfo.scale;
          this.translateX = this.originInfo.translateX;
          this.translateY = this.originInfo.translateY;
          this.clipWidth = this.originInfo.clipWidth;
          this.clipHeight = this.originInfo.clipHeight;
          this.radius = 0;
          this.opacityDegree = 0;
          // Start the sheet and modal pages.
          this.isShowSheet = true;
          this.isShowOverlay = true;
          // Set the original image to be non-interactive and interrupt-resistant.
          this.isEnabled = false;
        })
    }
    .width('100%')
    .height('100%')
    .padding({ top: 20 })
    .alignItems(HorizontalAlign.Center)
    .bindSheet(this.isShowSheet, this.mySheet(), {
      // EMBEDDED mode allows other pages to be higher than the sheet page.
      mode: SheetMode.EMBEDDED,
      height: this.bindSheetHeight,
      onDisappear: () => {
        // Ensure that the state is correct when the sheet disappears.
        this.isShowImage = false;
        this.isShowSheet = false;
        // Set the shared element transition animation to be triggerable again.
        this.isAnimating = false;
        // The original image becomes interactive again.
        this.isEnabled = true;
      }
    }) // Mount the modal page as the implementation page for the shared element transition animation.
    .bindContentCover(this.isShowOverlay, this.overlayNode(), {
      // Set the modal page to have no transition.
      transition: TransitionEffect.IDENTITY,
    })
  }

  // Sheet page.
  @Builder
  mySheet() {
    Column({space: 20}) {
      Text('Sheet')
        .fontSize(30)
      Row({space: 40}) {
        Column({space: 20}) {
          ForEach([1, 2, 3, 4], () => {
            Stack()
              .backgroundColor(Color.Pink)
              .borderRadius(20)
              .width(60)
              .height(60)
          })
        }
        Column() {
          if (this.isShowImage) {
            // Custom image node for the sheet page.
            ImageNode()
          }
          else {
            // For capturing layout and placeholder use, not actually displayed.
            // Customize the image resource path as needed.
            Image($r("app.media.flower"))
              .visibility(Visibility.Hidden)
          }
        }
        .height(300)
        .width(200)
        .borderRadius(20)
        .clip(true)
        .id('target')
      }
      .alignItems(VerticalAlign.Top)
    }
    .alignItems(HorizontalAlign.Start)
    .height('100%')
    .width('100%')
    .margin(40)
  }

  @Builder
  overlayNode() {
    // Set alignContent to TopStart for Stack; otherwise, during height changes, both the snapshot and content will be repositioned with the height relayout.
    Stack({ alignContent: Alignment.TopStart }) {
      ImageNode()
    }
    .scale({ x: this.scaleValue, y: this.scaleValue, centerX: undefined, centerY: undefined})
    .translate({ x: this.translateX, y: this.translateY })
    .width(this.clipWidth)
    .height(this.clipHeight)
    .borderRadius(this.radius)
    .clip(true)
  }
}

@Component
struct ImageNode {
  @State myNodeController: MyNodeController | undefined = new MyNodeController(false);

  aboutToAppear(): void {
    // Obtain the custom node.
    let node = getMyNode();
    if (node == undefined) {
      // Create a custom node.
      createMyNode(this.getUIContext());
    }
    this.myNodeController = getMyNode();
  }

  aboutToDisappear(): void {
    if (this.myNodeController != undefined) {
      // The node is removed from the tree.
      this.myNodeController.onRemove();
    }
  }
  build() {
    NodeContainer(this.myNodeController)
  }
}
// CustomComponent.ets
// Custom placeholder node with cross-container migration capability
import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI';

@Builder
function flowerBuilder() {
  // Replace $r('app.media.longevity_flower') with the actual resource file.
  Image($r('app.media.longevity_flower'))
  // Prevent flickering of the image during the first load.
    .syncLoad(true);
}

export class MyNodeController extends NodeController {
  private flowerNode: BuilderNode<[]> | null = null;
  private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(flowerBuilder);
  private needCreate: boolean = false;
  private isRemove: boolean = false;

  constructor(create: boolean) {
    super();
    this.needCreate = create;
  }

  makeNode(uiContext: UIContext): FrameNode | null {
    if (this.isRemove === true) {
      return null;
    }
    if (this.needCreate && this.flowerNode === null) {
      this.flowerNode = new BuilderNode(uiContext);
      this.flowerNode.build(this.wrapBuilder);
    }
    if (this.flowerNode === null) {
      return null;
    }
    return this.flowerNode!.getFrameNode()!;
  }

  getNode(): BuilderNode<[]> | null {
    return this.flowerNode;
  }

  setNode(node: BuilderNode<[]> | null) {
    this.flowerNode = node;
    this.rebuild();
  }

  onRemove() {
    this.isRemove = true;
    this.rebuild();
    this.isRemove = false;
  }

  init(uiContext: UIContext) {
    this.flowerNode = new BuilderNode(uiContext);
    this.flowerNode.build(this.wrapBuilder);
  }
}

let myNode: MyNodeController | undefined;

export const createMyNode =
  (uiContext: UIContext) => {
    myNode = new MyNodeController(false);
    myNode.init(uiContext);
  }

export const getMyNode = (): MyNodeController | undefined => {
  return myNode;
}
// ComponentAttrUtils.ets
// Obtain the position of the component relative to the window.
import { componentUtils, UIContext } from '@kit.ArkUI';
import { JSON } from '@kit.ArkTS';

export class ComponentAttrUtils {
  // Obtain the position information of the component based on its ID.
  public static getRectInfoById(context: UIContext, id: string): RectInfoInPx {
    if (!context || !id) {
      throw Error('object is empty');
    }
    let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id);

    if (!componentInfo) {
      throw Error('object is empty');
    }

    let rstRect: RectInfoInPx = new RectInfoInPx();
    const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2;
    const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2;
    rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap;
    rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap;
    rstRect.right =
      componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap;
    rstRect.bottom =
      componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap;
    rstRect.width = rstRect.right - rstRect.left;
    rstRect.height = rstRect.bottom - rstRect.top;
    return {
      left: rstRect.left,
      right: rstRect.right,
      top: rstRect.top,
      bottom: rstRect.bottom,
      width: rstRect.width,
      height: rstRect.height
    }
  }
}

export class RectInfoInPx {
  public left: number = 0;
  public top: number = 0;
  public right: number = 0;
  public bottom: number = 0;
  public width: number = 0;
  public height: number = 0;
}

export class RectJson {
  public $rect: Array<number> = [];
}
// WindowUtils.ets
// Window information
import { window } from '@kit.ArkUI';

export class WindowUtils {
  public static window: window.Window;
  public static windowWidthPx: number;
  public static windowHeightPx: number;
  public static topAvoidAreaHeightPx: number;
  public static navigationIndicatorHeightPx: number;
}
// EntryAbility.ets
// Add capture of window width and height in onWindowStageCreate at the application entry.

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { WindowUtils } from '../utils/WindowUtils';
import { display, window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN = 0x0000;
const TAG: string = 'EntryAbility';

export default class EntryAbility extends UIAbility {
  private currentBreakPoint: string = '';

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onCreate');
  }

  onDestroy(): void {
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onDestroy');
  }

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // Main window is created, set main page for this ability
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageCreate');
    // ...
    // Obtain the window width and height.
    WindowUtils.window = windowStage.getMainWindowSync();
    WindowUtils.windowWidthPx = WindowUtils.window.getWindowProperties().windowRect.width;
    WindowUtils.windowHeightPx = WindowUtils.window.getWindowProperties().windowRect.height;

    this.updateBreakpoint(WindowUtils.windowWidthPx);

    // Obtain the height of the upper avoid area (such as the status bar).
    let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
    WindowUtils.topAvoidAreaHeightPx = avoidArea.topRect.height;

    // Obtain the height of the navigation bar.
    let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
    WindowUtils.navigationIndicatorHeightPx = navigationArea.bottomRect.height;

    hilog.info(DOMAIN, TAG, 'the width is ' + WindowUtils.windowWidthPx + '  ' + WindowUtils.windowHeightPx + '  ' +
    WindowUtils.topAvoidAreaHeightPx + '  ' + WindowUtils.navigationIndicatorHeightPx);

    // Listen for changes in the window size, status bar height, and navigation bar height, and update accordingly.
    try {
      WindowUtils.window.on('windowSizeChange', (data) => {
        hilog.info(DOMAIN, TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height);
        WindowUtils.windowWidthPx = data.width;
        WindowUtils.windowHeightPx = data.height;
        this.updateBreakpoint(data.width);
        AppStorage.setOrCreate('windowSizeChanged', Date.now());
      })

      WindowUtils.window.on('avoidAreaChange', (data) => {
        if (data.type === window.AvoidAreaType.TYPE_SYSTEM) {
          let topRectHeight = data.area.topRect.height;
          hilog.info(DOMAIN, TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight);
          WindowUtils.topAvoidAreaHeightPx = topRectHeight;
        } else if (data.type === window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
          let bottomRectHeight = data.area.bottomRect.height;
          hilog.info(DOMAIN, TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight);
          WindowUtils.navigationIndicatorHeightPx = bottomRectHeight;
        }
      })
    } catch (exception) {
      hilog.error(DOMAIN, TAG, `register failed. code: ${exception.code}, message: ${exception.message}`);
    }

    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
        return;
      }
      hilog.info(DOMAIN, TAG, 'Succeeded in loading the content.');
    });
  }

  updateBreakpoint(width: number) {
    let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160);
    let newBreakPoint: string = '';
    if (windowWidthVp < 400) {
      newBreakPoint = 'xs';
    } else if (windowWidthVp < 600) {
      newBreakPoint = 'sm';
    } else if (windowWidthVp < 800) {
      newBreakPoint = 'md';
    } else {
      newBreakPoint = 'lg';
    }
    if (this.currentBreakPoint !== newBreakPoint) {
      this.currentBreakPoint = newBreakPoint;
      // Use the state variable to record the current breakpoint value.
      AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint);
    }
  }
  onWindowStageDestroy(): void {
    // Main window is destroyed, release UI related resources
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onWindowStageDestroy');
  }

  onForeground(): void {
    // Ability has brought to foreground
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onForeground');
  }

  onBackground(): void {
    // Ability has back to background
    hilog.info(DOMAIN, TAG, '%{public}s', 'Ability onBackground');
  }
}

Using geometryTransition

geometryTransition facilitates implicit shared element transitions within components, offering a smooth transition experience during view state changes.

To 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.

This method is ideal for shared element transitions between two distinct objects.

[h2]Simple Use of geometryTransition

Below is a simple example of using geometryTransition to implement shared element transition for two elements on the same page:

import { curves } from '@kit.ArkUI';

@Entry
@Component
struct IfElseGeometryTransition {
  @State isShow: boolean = false;

  build() {
    Stack({ alignContent: Alignment.Center }) {
      if (this.isShow) {
        // Replace $r('app.media.spring') with the actual resource file.
        Image($r('app.media.spring'))
          .autoResize(false)
          .clip(true)
          .width(200)
          .height(200)
          .borderRadius(100)
          .geometryTransition('picture')
          .transition(TransitionEffect.OPACITY)
          // If a new transition is triggered during the animation, ghosting occurs when id is not specified.
          // With id specified, the new spring image reuses the previous spring image node instead of creating a new node. Therefore, ghosting does not occur.
          // 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.
          .id('item1')
      } else {
        // geometryTransition is bound to a container. Therefore, a relative layout must be configured for the child components of the container.
        // The multiple levels of containers here are used to demonstrate passing of relative layout constraints.
        Column() {
          Column() {
            // Replace $r('app.media.sunset_sky') with the actual resource file.
            Image($r('app.media.sunset_sky'))
              .size({ width: '100%', height: '100%' })
          }
          .size({ width: '100%', height: '100%' })
        }
        .width(100)
        .height(100)
        // geometryTransition synchronizes corner radius settings, but only for the bound component, which is the container in this example.
        // In other words, corner radius settings of the container are synchronized, and those of the child components are not.
        .borderRadius(50)
        .clip(true)
        .geometryTransition('picture')
        // transition ensures that the component is not destroyed immediately when it exits. You can customize the transition effect.
        .transition(TransitionEffect.OPACITY)
        .position({ x: 40, y: 40 })
        .id('item2')
      }
    }
    .onClick(() => {
      this.getUIContext()?.animateTo({
        curve: curves.springMotion()
      }, () => {
        this.isShow = !this.isShow;
      })
    })
    .size({ width: '100%', height: '100%' })
  }
}

[h2]Combining geometryTransition with Modal Transition

By 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.

import { common } from '@kit.AbilityKit';

class PostData {
  // Replace $r('app.media.flower') with the actual resource file.
  avatar: Resource = $r('app.media.flower');
  name: string = '';
  message: ResourceStr = '';
  images: Resource[] = [];
}

@Entry
@Component
struct Index {
  @State isPersonalPageShow: boolean = false;
  @State selectedIndex: number = 0;
  @State alphaValue: number = 1;
  private context = this.getUIContext().getHostContext() as common.UIAbilityContext;

  // Customize the image resource paths in the array as needed.
  private allPostData: PostData[] = [
    {
      // Replace $r('app.media.flower') with the actual resource file.
      avatar: $r('app.media.flower'),
      name: 'Alice',
      // Replace $r('app.string.shareTransition_text1') with the actual resource file. In this example, the value in the resource file is "Weather is fine."
      message: $r('app.string.shareTransition_text1'),
      // Replace $r('app.media.spring') with the actual resource file.
      // Replace $r('app.media.tall_tree') with the actual resource file.
      images: [$r('app.media.spring'), $r('app.media.tall_tree')]
    },
    {
      // Replace $r('app.media.sunset_sky') with the actual resource file.
      avatar: $r('app.media.sunset_sky'),
      name: 'Bob',
      // Replace $r('app.string.shareTransition_text2') with the actual resource file. In this example, the value in the resource file is "Hello World."
      message: $r('app.string.shareTransition_text2'),
      // Replace $r('app.media.island') with the actual resource file.
      images: [$r('app.media.island')]
    },
    {
      // Replace $r('app.media.tall_tree') with the actual resource file.
      avatar: $r('app.media.tall_tree'),
      name: 'Carl',
      // Replace $r('app.string.shareTransition_text3') with the actual resource file. In this example, the value in the resource file is "All things grow."
      message: $r('app.string.shareTransition_text3'),
      // Replace $r('app.media.flower') with the actual resource file.
      // Replace $r('app.media.sunset_sky') with the actual resource file.
      // Replace $r('app.media.spring') with the actual resource file.
      images: [$r('app.media.flower'), $r('app.media.sunset_sky'), $r('app.media.spring')]
    }];

  private onAvatarClicked(index: number): void {
    this.selectedIndex = index;
    this.getUIContext()?.animateTo({
      duration: 350,
      curve: Curve.Friction
    }, () => {
      this.isPersonalPageShow = !this.isPersonalPageShow;
      this.alphaValue = 0;
    });
  }

  private onPersonalPageBack(index: number): void {
    this.getUIContext()?.animateTo({
      duration: 350,
      curve: Curve.Friction
    }, () => {
      this.isPersonalPageShow = !this.isPersonalPageShow;
      this.alphaValue = 1;
    });
  }

  @Builder
  PersonalPageBuilder(index: number) {
    Column({ space: 20 }) {
      Image(this.allPostData[index].avatar)
        .size({ width: 200, height: 200 })
        .borderRadius(100)
        // Apply a shared element transition to the profile picture by its ID.
        .geometryTransition(index.toString())
        .clip(true)
        .transition(TransitionEffect.opacity(0.99))

      Text(this.allPostData[index].name)
        .font({ size: 30, weight: 600 })
        // Apply a transition effect to the text.
        .transition(TransitionEffect.asymmetric(
          TransitionEffect.OPACITY
            .combine(TransitionEffect.translate({ y: 100 })),
          TransitionEffect.OPACITY.animation({ duration: 0 })
        ))

      // Configure the resource whose name is 'shareTransition_text11' and value is a non-empty string in the resources\base\element\string.json file.
      Text(this.context.resourceManager.getStringByNameSync('shareTransition_text11') + this.allPostData[index].name)
      // Apply a transition effect to the text.
        .transition(TransitionEffect.asymmetric(
          TransitionEffect.OPACITY
            .combine(TransitionEffect.translate({ y: 100 })),
          TransitionEffect.OPACITY.animation({ duration: 0 })
        ))
    }
    .padding({ top: 20 })
    .size({ width: 360, height: 780 })
    .backgroundColor(Color.White)
    .onClick(() => {
      this.onPersonalPageBack(index);
    })
    .transition(TransitionEffect.asymmetric(
      TransitionEffect.opacity(0.99),
      TransitionEffect.OPACITY
    ))
  }

  build() {
    Column({ space: 20 }) {
      ForEach(this.allPostData, (postData: PostData, index: number) => {
        Column() {
          Post({
            data: postData, index: index, onAvatarClicked: (index: number) => {
              this.onAvatarClicked(index);
            }
          })
        }
        .width('100%')
      }, (postData: PostData, index: number) => index.toString())
    }
    .size({ width: '100%', height: '100%' })
    .backgroundColor('#40808080')
    .bindContentCover(this.isPersonalPageShow,
      this.PersonalPageBuilder(this.selectedIndex), { modalTransition: ModalTransition.NONE })
    .opacity(this.alphaValue)
  }
}

@Component
export default struct Post {
  @Prop data: PostData;
  @Prop index: number;
  @State expandImageSize: number = 100;
  @State avatarSize: number = 50;
  private onAvatarClicked: (index: number) => void = (index: number) => { };

  build() {
    Column({ space: 20 }) {
      Row({ space: 10 }) {
        Image(this.data.avatar)
          .size({ width: this.avatarSize, height: this.avatarSize })
          .borderRadius(this.avatarSize / 2)
          .clip(true)
          .onClick(() => {
            this.onAvatarClicked(this.index);
          })
          // ID of the shared element transition bound to the profile picture.
          .geometryTransition(this.index.toString(), { follow: true })
          .transition(TransitionEffect.OPACITY.animation({ duration: 350, curve: Curve.Friction }))

        Text(this.data.name)
      }
      .justifyContent(FlexAlign.Start)

      Text(this.data.message)

      Row({ space: 15 }) {
        ForEach(this.data.images, (imageResource: Resource, index: number) => {
          Image(imageResource)
            .size({ width: 100, height: 100 })
        }, (imageResource: Resource, index: number) => index.toString())
      }
    }
    .backgroundColor(Color.White)
    .size({ width: '100%', height: 250 })
    .alignItems(HorizontalAlign.Start)
    .padding({ left: 10, top: 10 })
  }
}

After 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.

Shared Element Transition Cases

[h2]Long-Take Animations for Image Expansion

[h2]Long-Take Animations for Icon (Including the Search Box and Profile Picture) Expansion

This example demonstrates how to implement a long-take animation for transitioning the search box from the home page to the search page.

Set the geometryTransition attribute for the Search components on both the home page and search box page and bind the same ID to them. Configure the explicit animations and transition attributes to create a seamless long-take transition effect for the search box.

  1. Add the geometryTransition attribute to the Search component on the home page, bind the ID, and disable the transition animation of Navigation.

    private showSearchPage(): void {
      this.transitionEffect = TransitionEffect.OPACITY;
      this.getUIContext().animateTo({
        curve: curves.interpolatingSpring(0, 1, 342, 38)
      }, () => {
        this.pageInfos.pushPath({ name: 'SearchLongTakeTransitionPageTwo' }, false);
      })
    }
  2. Add geometryTransition() to the Search component on the search page and add the same ID as that of the search box on the home page.

    Search({ placeholder: 'Search' })
      .height(40)
      .placeholderColor($r('sys.color.mask_secondary'))
      .width('100%')
      .geometryTransition('SEARCH_ONE_SHOT_DEMO_TRANSITION_ID', { follow: true })
      .backgroundColor('#0D000000')
      .defaultFocus(false)
      .focusOnTouch(false)
      .focusable(false)

Container Transition Cases

[h2]Long-Take Animations for Widget/List Expansion

In the waterfall or list layout, when a user taps a widget or list item, the app should execute a smooth transition animation to navigate from the overview page to the details page.

Use WaterFlow() and LazyForEach() to implement the card waterfall layout. Use customNavContentTransition() to customize the navigation transition animation for the list page and details page, and use componentSnapshot() to take a snapshot of the card to avoid a white screen during page redirection.

  1. Use WaterFlow and LazyForEach to implement the layout of widget list pages.

    private onColumnClicked(indexValue: string): void {
      let param: Record<string, Object> = {};
      let clickedIndex = parseInt(indexValue);
      param['indexValue'] = clickedIndex;
      this.clickedIndex = clickedIndex;
      this.getUIContext()
        .getComponentSnapshot()
        .get('FlowItem_' + indexValue, (error: BusinessError, pixelMap: image.PixelMap) => {
          if (error) {
            hilog.error(0x0000, 'CardLongTakePageOne',
              `componentSnapshot.get error, reason: Code is ${error.code}, message is ${error.message}`);
            this.pageInfos.pushPath({ name: 'CardLongTakeTransitionPageTwo', param: param });
            return;
          } else {
            hilog.info(0x0000, 'CardLongTakePageOne', 'componentSnapshot.get success!');
            param['clickedComponentId'] = CardUtil.getFlowItemIdByIndex(indexValue);
            param['doDefaultTransition'] = () => {
              this.doFinishTransition();
            };
            SnapShotImage.pixelMap = pixelMap;
            this.pageInfos.pushPath({ name: 'CardLongTakeTransitionPageTwo', param: param });
            this.dataSource.getData(this.clickedIndex).isVisible = Visibility.Hidden;
          }
        })
    }
  2. You can customize a long-take animation for the widget details page using Navigation. Two layers of Stack() are used here because screenshots need to be placed and the original details page content needs to be moved. Set the scaling and translate properties on the Stack() layer to implement boundary animations, and set the opacity property on the snapshot to achieve content transitions. Register custom animations in onReady(), and initialize animation properties through the ID.

    tryRegisterCustomTransition(clickedCardId: string): void {
      try {
        this.longTakeAnimationProperties.init(clickedCardId, this.prePageDoFinishTransition);
        CustomTransition.getInstance().registerNavParam(this.pageId, 2000,
          (transitionProxy: NavigationTransitionProxy) => {
            this.longTakeAnimationProperties.doAnimation(transitionProxy);
          });
        hilog.info(0x0000, 'CardLongTakePageTwo', 'register successes');
      } catch (error) {
        let err = error as BusinessError;
        hilog.error(0x0000, 'CardLongTakePageTwo', `this is error:code=${err.code}, message=${err.message}`);
        this.longTakeAnimationProperties.setFinalStatus();
      }
    }
    
    // ...
    
    build() {
      NavDestination() {
        Stack({ alignContent: Alignment.TopStart }) {
          Stack({ alignContent: Alignment.TopStart }) {
            Image(this.snapShotImage)
              .size(this.longTakeAnimationProperties.snapShotSize)
              .objectFit(ImageFit.Auto)
              .opacity(this.longTakeAnimationProperties.snapShotOpacity)
              .syncLoad(true)
              .position({
                x: this.longTakeAnimationProperties.snapShotPositionX,
                y: this.longTakeAnimationProperties.snapShotPositionY
              })
    
            DetailPageContent({
              indexValue: this.indexValue,
              pageInfos: this.pageInfos,
              onBackPressed: () => {
                this.onBackPressed()
              },
              SharedComponentId: CardUtil.getPostPageImageId(this.clickedCardId)
            })
              .size({
                width: '100%',
                height: '100%'
              })
              .opacity(this.longTakeAnimationProperties.postPageOpacity)
          }
          .width('100%')
          .position({
            x: this.longTakeAnimationProperties.positionXValue,
            y: this.longTakeAnimationProperties.positionYValue
          })
        }
        .scale({
          x: this.longTakeAnimationProperties.scaleValue,
          y: this.longTakeAnimationProperties.scaleValue
        })
        .translate({
          x: this.longTakeAnimationProperties.translateX,
          y: this.longTakeAnimationProperties.translateY
        })
        .width(this.longTakeAnimationProperties.clipWidth)
        .height(this.longTakeAnimationProperties.clipHeight)
        .borderRadius(this.longTakeAnimationProperties.radius)
        .expandSafeArea([SafeAreaType.SYSTEM])
        .backgroundColor($r('app.color.water_flow_background_color'))
        .clip(true)
      }
      .backgroundColor(this.longTakeAnimationProperties.navDestinationBgColor)
      .GestureStyles()
      .hideTitleBar(true)
      .onReady((context: NavDestinationContext) => {
        this.pageInfos = context.pathStack;
        let param = context.pathInfo?.param as Record<string, Object>;
        let clickedCardId = param['clickedComponentId'] as string;
        this.indexValue = param['indexValue'] as number;
        this.prePageDoFinishTransition = param['doDefaultTransition'] as () => void;
        if (context.navDestinationId && clickedCardId) {
          this.pageId = context.navDestinationId;
          this.clickedCardId = clickedCardId;
          this.tryRegisterCustomTransition(clickedCardId);
        }
      })
      .onBackPressed(() => {
        return this.onBackPressed();
      })
      .onDisAppear(() => {
        CustomTransition.getInstance().unRegisterNavParam(this.pageId);
      })
    }
    
    // ...

Below is the long-take animation effect for a list.

Set the geometryTransition attribute for list items on both the list page and details page and bind the same ID to them. Configure the explicit animations and transition attributes for each list item to create a seamless long-take transition effect during list expansion.

  1. Set the geometryTransition attribute for each list item on the list page and bind the ID of the current list.

    @Component
    export struct MyButton {
      @Prop listContent: ListContent;
      @Prop indexValue: string;
      @State scaleValue: number = 1;
    
      build() {
        Column({ space: 10 }) {
          Row({ space: 5 }) {
            Line()
              .startPoint([0, 0])
              .endPoint([0, 20])
              .strokeWidth(5)
              .stroke(Color.Yellow)
              .strokeLineCap(LineCapStyle.Round)
            Text(this.listContent.title)
              .fontWeight(FontWeight.Medium)
              .fontSize(16)
          }
    
          Text(this.listContent.content)
            .fontColor(Color.Grey)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .fontSize(14)
        }
        .alignItems(HorizontalAlign.Start)
        .padding({
          left: 20,
          right: 20,
          top: 20,
          bottom: 20
        })
        .width('91%')
        .backgroundColor(Color.White)
        .clip(true)
        .borderRadius(20)
        .scale({
          x: this.scaleValue,
          y: this.scaleValue
        })
        .geometryTransition(this.indexValue, { follow: true })
        .onTouch((event?: TouchEvent) => {
          this.onTouchProcess(event);
        })
        .onClick(() => {
          this.onButtonClicked?.(this.indexValue);
        })
      }
    
      onButtonClicked: (index: string) => void = (_index: string) => {
      };
    
      private onTouchProcess(event?: TouchEvent): void {
        if (!event) {
          return;
        }
        if (event.type === TouchType.Down) {
          this.getUIContext().animateTo({ curve: curves.interpolatingSpring(0, 1, 350, 35) }, () => {
            this.scaleValue = 0.95;
          })
        } else if (event.type === TouchType.Up) {
          this.getUIContext().animateTo({ curve: curves.interpolatingSpring(0, 1, 350, 35) }, () => {
            this.scaleValue = 1;
          })
        } else if (event.type === TouchType.Cancel) {
          this.getUIContext().animateTo({ curve: curves.interpolatingSpring(0, 1, 350, 35) }, () => {
            this.scaleValue = 1;
          })
        }
      }
    }
  2. Set the geometryTransition attribute for the Column component on the list details page and bind the ID of the corresponding list item to implement the long-take animation effect.

    NavDestination() {
      Column({ space: 20 }) {
        Text(this.param.title)
          .fontSize(30)
          .fontWeight(FontWeight.Medium)
        Text(this.param.content)
          .fontColor($r('sys.color.password_icon_focus_color'))
          .lineHeight(28)
          .fontSize(16)
      }
      .alignItems(HorizontalAlign.Start)
      .clip(true)
      .size({
        width: '100%',
        height: '100%'
      })
      .geometryTransition(this.param.geometryId)
    }
    .padding({
      top: 46,
      left: 16,
      right: 16
    })
    .backgroundColor(Constants.DEFAULT_BG_COLOR)
    .transition(TransitionEffect.OPACITY)
    .hideTitleBar(true)
    .backgroundColor(Color.Transparent)
    .onReady((context: NavDestinationContext) => {
      this.pageInfos = context.pathStack;
      this.param = (context.pathInfo.param as ListDetailPageExtraInfo);
    })
    .onBackPressed(() => {
      this.getUIContext().animateTo({ curve: curves.interpolatingSpring(0, 1, 342, 38) }, () => {
        this.pageInfos.pop(false);
      })
      return true;
    })

[h2]Long-Take Animations for Book Page-turning

In reading apps, when users tap the icon of a book, they will be redirected to the book content page. This is when the long-take animation is used. In addition, users can use gestures to return to the previous page.

Leverage the custom navigation transition capability of the Navigation component by configuring customNavContentTransition() to achieve a long-take animation between the book page and detail pages. Use the rotate attribute to implement the rotation effect for book page turning.

  1. On the bookshelf page, use the Grid and Swiper() components to implement the layout of books in the first row of the bookshelf.

    build() {
      NavDestination() {
        Scroll() {
          Column({ space: 12 }) {
            Grid() {
              ForEach(this.dataSource, (item: BookItem, index: number) => {
                GridItem() {
                  Image($r(item.coverImageUrl))
                    .id(item.id)
                    .width('100%')
                    .onClick(() => {
                      this.onColumnClicked(item.id, item.coverImageUrl, this.dataSource[0].id, () => {
                        this.dataSource.sort((a, b) => b.timestamp - a.timestamp);
                      })
                      this.dataSource[index].timestamp = Number(new Date());
                    })
                }
                .width(this.columnWidth)
              }, (item: BookItem) => JSON.stringify(item))
            }
            .padding({
              left: 12,
              right: 12,
              top: 12
            })
            .columnsTemplate(this.columnType)
            .columnsGap(10)
            .rowsGap(10)
    
            Column({ space: 12 }) {
              Text('Recently read')
                .fontSize(16)
                .fontWeight(FontWeight.Medium)
                .fontColor(Color.Gray)
              Swiper(this.swiperController) {
                ForEach(this.recentData, (item: BookItem) => {
                  GridItem() {
                    Image($r(item.coverImageUrl))
                      .id(item.id)
                      .onClick(() => {
                        this.onColumnClicked(item.id, item.coverImageUrl);
                      })
                  }
                }, (item: BookItem) => JSON.stringify(item))
              }
              .indicator(false)
              .displayCount(3)
              .loop(false)
              .itemSpace(10)
            }
            .padding({
              left: 12,
              right: 12
            })
            .alignItems(HorizontalAlign.Start)
          }
        }
      }
      // ...
    }
    
    // ...
    
    private onColumnClicked(bookId: string, bookCoverUrl: string, toBookId?: string, prePageCallback?: () => void): void {
      try {
        CustomTransition.getInstance().unRegisterNavParam(this.pageId);
        const fromCardItemInfo: RectInfoInPx =
          ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), bookId);
        let param: Record<string, Object> = {};
        param['fromCardItemInfo'] = fromCardItemInfo;
        param['bookCoverUrl'] = bookCoverUrl;
        if (toBookId) {
          const toCardItemInfo: RectInfoInPx =
            ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), toBookId);
          param['toCardItemInfo'] = toCardItemInfo;
        }
        if (prePageCallback) {
          param['prePageCallback'] = prePageCallback;
        }
        this.pageInfos.pushPath({ name: 'BookFlipLongTakeTransitionPageTwo', param: param });
      } catch (err) {
        let error = err as BusinessError;
        hilog.error(0x0000, 'BookFlipLongTakeTransitionPageOne',
          `onColumnClicked failed. error code=${error.code}, message=${error.message}`);
      }
    }
  2. Customize a long-take animation for the book details page using Navigation.

    NavDestination() {
      Stack() {
        Column() {
          Text($r('app.string.DetailPage_text'))
            .fontColor($r('sys.color.password_icon_focus_color'))
            .lineHeight(28)
            .fontSize(16)
        }
        .width(AppStorage.get('currentBreakpoint') === 'md' ? '75%' : '100%')
        .height('100%')
        .alignItems(HorizontalAlign.Start)
        .padding({
          left: 16,
          right: 16,
          top: 46
        })
    
        if (!this.doDefaultTransition) {
          Image($r(this.bookCoverUrl))
            .objectFit(ImageFit.Cover)
            .syncLoad(true)
            .rotate({
              x: 0,
              y: 1,
              z: 0,
              angle: this.bookFlipLongTakeTransitionProperties.coverRotateAngle,
              centerX: 0,
              centerY: '50%'
            })
            .scale({
              x: this.bookFlipLongTakeTransitionProperties.coverScale,
              centerX: 0,
              centerY: '50%'
            })
        }
      }
      .scale({
        x: this.bookFlipLongTakeTransitionProperties.scaleValue,
        y: this.bookFlipLongTakeTransitionProperties.scaleValue
      })
      .translate({
        x: this.bookFlipLongTakeTransitionProperties.translateX,
        y: this.bookFlipLongTakeTransitionProperties.translateY
      })
      .width(this.bookFlipLongTakeTransitionProperties.clipWidth)
      .height(this.bookFlipLongTakeTransitionProperties.clipHeight)
      .backgroundColor('#DEDFDF')
    }
    .backgroundColor(this.bookFlipLongTakeTransitionProperties.navDestinationBgColor)
    .GestureStyles()
    .hideTitleBar(true)
    .onReady((context: NavDestinationContext) => {
      this.pageInfos = context.pathStack;
      let param = context.pathInfo?.param as Record<string, Object>;
      this.bookCoverUrl = param['bookCoverUrl'] as string;
      this.fromCardItemInfo = param['fromCardItemInfo'] as RectInfoInPx;
      this.toCardItemInfo = (param['toCardItemInfo'] || param['fromCardItemInfo']) as RectInfoInPx;
      this.prePageCallback = param['prePageCallback'] as () => void;
      if (context.navDestinationId) {
        this.pageId = context.navDestinationId;
      }
      CustomTransition.getInstance()
        .registerNavParam(this.pageId, 500, (transitionProxy: NavigationTransitionProxy) => {
          this.bookFlipLongTakeTransitionProperties.doAnimation(transitionProxy, this.fromCardItemInfo,
            this.toCardItemInfo);
        }, () => {
          this.bookFlipLongTakeTransitionProperties.onInteractiveFinish();
        }, () => {
          this.bookFlipLongTakeTransitionProperties.onInteractive(
            this.fromCardItemInfo, this.toCardItemInfo);
        });
    })
    .onBackPressed(() => {
      return this.onBackPressed();
    })
    .onDisAppear(() => {
      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
    })

[h2]Long-Take Animations for Video Expansion

This example shows how to implement a long-take animation for transitioning a video between pages. During the transitioning, the video playback continues.

Use WaterFlow() and LazyForEach() to implement the card waterfall layout. Use NodeController to implement cross-node component migration. Use customNavContentTransition to customize the navigation transition animation of the overview page and video details page to provide a long-take effect for the node migration process.

  1. Create a NodeController class.

    export class MyNodeController extends NodeController {
      // ...
    }
  2. Implement the video home page layout using WaterFlow() and LazyForEach(). When a video is tapped, migrate the video node to the playback page and execute a long-take animation through custom Navigation configurations.

    NavDestination() {
      WaterFlow() {
        LazyForEach(this.dataSource, (_: CardAttr, index: number) => {
          FlowItem() {
            VideoCardComponent({
              isPlaying: false,
              index,
              onColumnClicked: (prePageCallback) => {
                this.onColumnClicked(`xComponent_${index}`, prePageCallback)
              }
            })
          }
          .width('100%')
          .borderRadius(10)
          .clip(true)
          .id('FlowItem_' + index.toString())
        }, (item: string) => item)
      }
      .edgeEffect(EdgeEffect.Spring)
      .onScrollIndex((first: number) => {
        this.scrollFirstIndex = first;
      })
      .padding(12)
      .columnsTemplate(this.columnType)
      .columnsGap(12)
      .rowsGap(10)
      .width('100%')
      .height('100%')
    }
    .backgroundColor(Constants.DEFAULT_BG_COLOR)
    .title(getResourceString(this.getUIContext(), $r('app.string.video_title'), this))
    .onReady((context: NavDestinationContext) => {
      this.pageInfos = context.pathStack;
      if (context.navDestinationId) {
        this.pageId = context.navDestinationId;
      }
    })
    .onDisAppear(() => {
      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
    })