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. |
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:
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.
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;
})
})
}
} 
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).
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:
When expanding a widget, obtain the position of the clicked widget A and migrate widget A to the expanded page B, which is positioned at the same location as widget A. The expanded page B has a higher stacking order than the clicked widget A.
Add a property animation to the expanded page B to expand and move it to the expanded position, completing a shared element transition effect.
When collapsing the widget, add a property animation to the expanded page B to collapse and move it back to its original position (the position of clicked widget A), achieving a shared element transition effect.
In the animation completion callback, migrate the components from the expanded page B back to the clicked widget A.
// 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);
} 
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:
Mount both a sheet and a full-modal transition on the initial screen: Design the sheet as required, and place only the necessary components for the shared element transition on the full-modal page. Capture layout information to position it over the image on the initial screen. When the image is clicked, trigger both the sheet and full-modal pages to appear, with the full-modal set to SheetMode.EMBEDDED for the highest layer.
Place an invisible placeholder image on the sheet: This will be the final position for the image after the shared element transition. Use a layout callback to listen for when the placeholder image's layout is complete, then obtain its position and start the shared element transition with property animation from the full-modal page's image.
End the animation on the full-modal page: When the animation ends, trigger a callback to close the full-modal page and migrate the shared element image node to the sheet, replacing the placeholder.
Account for height differences: The sheet may have varying elevations, affecting its starting position compared to the full-modal, which is full-screen. Calculate and adjust for these height differences during the shared element transition, as demonstrated in the demo.
Enhance with additional animation: Optionally, add an animation to the initial image that transitions from transparent to visible to smooth the overall effect.
├──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');
}
} 
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.
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%' })
}
} 
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.

Transition for zoom-in with two fingers
This example demonstrates how to use the pinch gesture to switch between the thumbnail mode and full-size mode.

Use the NodeContainer component to implement cross-node migration. Enable users to pinch in or out to mount the nodes to or remove the nodes from the tree, achieving long-take animations.
Implement the thumbnail mode and full-size mode as two separate pages. Listen for changes in the expand value to switch between these modes.
@StorageProp('expand') @Watch('goToPageTwo') num1: number = 0;
// ...
aboutToAppear(): void {
if (!getMyNode()) {
createMyNode(this.getUIContext(), false);
}
this.imageGalleryNodeController = getMyNode();
} Create a NodeContainer class.
export class ImageGalleryNodeController extends NodeController {
private rootNode: BuilderNode<[Params]> | null = null;
private wrapBuilder: WrappedBuilder<[Params]> = wrapBuilder(ImageGalleryBuilder);
private isExpand: boolean = false;
constructor(isExpand: boolean) {
super();
this.isExpand = isExpand;
}
makeNode(uiContext: UIContext): FrameNode | null {
if (this.rootNode === null) {
this.rootNode = new BuilderNode(uiContext);
this.rootNode.build(this.wrapBuilder, { isExpand: this.isExpand });
}
return this.rootNode.getFrameNode();
}
init(uiContext: UIContext) {
this.rootNode = new BuilderNode(uiContext);
this.rootNode.build(this.wrapBuilder, { isExpand: this.isExpand });
}
update(isExpand: boolean) {
if (this.rootNode !== null) {
this.rootNode.update({ isExpand });
}
}
} Handle the pinch gesture in thumbnail mode. When a pinch gesture is detected, change the isExpand value.
PinchGesture()
.onActionStart((event: GestureEvent) => {
this.offsetY = getTranslateToFullScreen(this.getUIContext(), 'swiper')?.offsetY || 0
this.imageHeight = this.getUIContext().vp2px(Number(event.target.area.height))
this.imageWidth = this.getUIContext().vp2px(Number(event.target.area.width))
this.status = Status.PINCHING;
this.updateCenter([this.getUIContext().vp2px(event.pinchCenterX),
this.getUIContext().vp2px(event.pinchCenterY)])
this.updateTranslateAccordingToCenter();
this.startGestureScale = this.imageScale;
this.gestureCount++;
})
.onActionUpdate((event: GestureEvent) => {
this.imageScale = this.startGestureScale * event.scale;
if (!this.isExpand && this.imageScale >= 1) {
this.onExpand();
}
this.updateExtremeOffset();
}) When the expand value is changed, switch the page to the full-size mode with a long-take animation.
NavDestination() {
NodeContainer(this.imageGalleryNodeController)
}
.mode(NavDestinationMode.DIALOG)
.height('100%')
.width('100%')
.hideTitleBar(true)
.onReady((context: NavDestinationContext) => {
this.pageInfo = context.pathStack;
const param = context.pathInfo?.param as Record<string, Object>;
this.onBack = param['onBack'] as () => void;
})
.onBackPressed(() => {
AppStorage.setOrCreate('reset', new Date());
this.getUIContext().animateTo({ duration: 300, curve: Curve.EaseIn }, () => {
this.backToPageOne();
})
return true;
}) Transition for large image viewing
This example demonstrates how to create a 3-by-3 image grid. Users can tap any thumbnail to view the full-size image and swipe down to return to the grid.

Use geometryTransition to bind the thumbnail view and large-size view of an image to the same ID to implement a long-take animation. Sample code:
Implement a three-row, three-column image layout using a grid for the home page. Set geometryTransition for each image, binding a unique ID for two shared Image components.
NavDestination() {
Column() {
Grid(this.scroller) {
ForEach(this.data, (item: number) => {
GridItem() {
if (this.clickedIndex !== item || (this.isFirstPageShow)) {
Image($r(`app.media.img_${item % 9}`))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.id('item2_' + item)
.onClick(() => {
this.onItemClick(item);
})
.geometryTransition(this.clickedIndex === item ? 'app.media.img_' + item.toString() : '')
.transition(TransitionEffect.opacity(0.99))
}
}
.width(this.getUIContext().px2vp(381))
.height(this.getUIContext().px2vp(381))
}, (item: number) => item + '')
}
.rowsTemplate('1fr 1fr 1fr')
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(2)
.rowsGap(2)
.size({
width: this.getUIContext().px2vp(1169),
height: this.getUIContext().px2vp(1169)
})
.margin({ top: 16 })
}
}
.title('View larger picture')
.height('100%')
.width('100%')
.onReady((context: NavDestinationContext) => {
this.pageInfo = context.pathStack;
}) Use a property animation for switch between the thumbnail view and large-size view of an image. To avoid triggering the Navigation's transition animation, set the animation option to false in pushPath().
onItemClick(index: number): void {
let param: Record<string, Object> = {};
this.needFollow = false;
this.clickedIndex = index;
param['selectedIndex'] = this.clickedIndex;
param['onIndexChange'] = (index: number) => {
this.onIndexChange(index);
};
param['onBackToFirstPage'] = () => {
this.onBack();
}
this.getUIContext().animateTo({
duration: 250,
curve: Curve.EaseIn,
}, () => {
this.pageInfo.pushPath({ name: 'ShowLargeImageWithGesturePageTwo', param: param }, false);
this.isFirstPageShow = false;
})
} Sheet transition
This example shows how to implement a sheet transition for images.

Use the NodeContainer component to implement cross-node migration. Set mode in SheetOptions() to SheetMode.EMBEDDED. In this mode, new pages can overlay the sheet, and when the user returns to the previous page, the sheet remains present without losing its content. With the property animation, the component transitions smoothly from the initial page to a sheet with a long-take animation. When the animation ends, the current page is closed, and the component is migrated to the sheet.
Create a NodeContainer class.
export class MyNodeController extends NodeController {
// ...
} Bind the target image on the home page to a sheet and set the modal transition animation through bindContentCover.
NavDestination() {
Column() {
Image($r('app.media.flower'))
.opacity(this.opacityDegree)
.width('90%')
.id('origin')
.enabled(this.isEnabled)
.onClick(() => {
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;
this.isShowSheet = true;
this.isShowOverlay = true;
this.isEnabled = false;
})
}
.width('100%')
.height('100%')
.padding({ top: 16 })
.alignItems(HorizontalAlign.Center)
.bindSheet(this.isShowSheet, this.mySheet(), {
mode: SheetMode.EMBEDDED,
height: this.bindSheetHeight,
onDisappear: () => {
this.isShowImage = false;
this.isShowSheet = false;
this.isAnimating = false;
this.isEnabled = true;
}
})
.bindContentCover(this.isShowOverlay, this.overlayNode(), {
transition: TransitionEffect.IDENTITY
})
}
.backgroundColor('#F1F3F5')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
.title('half mode') After a user taps the image on the home page, migrate the image node to the sheet. When the sheet layout is complete, trigger the onLayoutComplete() function to obtain the initial position and sheet position of the image, and customize the display animation to complete the long-take effect.
aboutToAppear(): void {
let onLayoutComplete: () => void = (): void => {
this.targetInfo = this.calculateData('target');
if (this.targetInfo.scale !== 0 && this.targetInfo.clipWidth !== 0 && this.targetInfo.clipHeight !== 0 &&
!this.isAnimating) {
this.isAnimating = true;
this.getUIContext().animateTo({
duration: 1000,
curve: Curve.Friction,
onFinish: () => {
this.isShowOverlay = false;
this.isShowImage = true;
}
}, () => {
this.scaleValue = AppStorage.get('currentBreakpoint') === 'md' ? 0.382 : this.targetInfo.scale;
this.translateX = AppStorage.get('currentBreakpoint') === 'md' ? 93.5 : this.targetInfo.translateX;
this.clipWidth = AppStorage.get('currentBreakpoint') === 'md' ? 525 : this.targetInfo.clipWidth;
this.clipHeight = AppStorage.get('currentBreakpoint') === 'md' ? 785 : this.targetInfo.clipHeight;
this.translateY = this.targetInfo.translateY +
(this.getUIContext().px2vp(WindowUtils.windowHeight_px) - this.bindSheetHeight -
this.getUIContext().px2vp(WindowUtils.navigationIndicatorHeight_px) -
this.getUIContext().px2vp(WindowUtils.topAvoidAreaHeight_px)) -
(AppStorage.get('currentBreakpoint') === 'md' ? 134.3 : 0);
this.radius = this.sheetRadius / this.scaleValue;
})
this.getUIContext().animateTo({
duration: 2000,
curve: Curve.Friction,
}, () => {
this.opacityDegree = 1;
})
}
};
this.listener.on('layout', onLayoutComplete);
} 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.
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);
})
} 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) 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.
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;
}
})
} 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.
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;
})
}
}
} 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;
}) 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.
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}`);
}
} 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);
}) 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.
Create a NodeController class.
export class MyNodeController extends NodeController {
// ...
} 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);
})