• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# 共享元素转场 (一镜到底)
2<!--Kit: ArkUI-->
3<!--Subsystem: ArkUI-->
4<!--Owner: @CCFFWW-->
5<!--Designer: @yangfan229-->
6<!--Tester: @lxl007-->
7<!--Adviser: @ge-yafang-->
8
9共享元素转场是一种界面切换时对相同或者相似的两个元素做的一种位置和大小匹配的过渡动画效果,也称一镜到底动效。
10
11如下例所示,在点击图片后,该图片消失,同时在另一个位置出现新的图片,二者之间内容相同,可以对它们添加一镜到底动效。左图为不添加一镜到底动效的效果,右图为添加一镜到底动效的效果,一镜到底的效果能够让二者的出现消失产生联动,使得内容切换过程显得灵动自然而不生硬。
12
13![zh-cn_image_0000001599644876](figures/zh-cn_image_0000001599644876.gif)|![zh-cn_image_0000001599644877](figures/zh-cn_image_0000001599644877.gif)
14---|---
15
16一镜到底的动效有多种实现方式,在实际开发过程中,应根据具体场景选择合适的方法进行实现。
17
18以下是不同实现方式的对比:
19
20| 一镜到底实现方式 | 特点 | 适用场景 |
21| ------ | ---- | ---- |
22| 不新建容器直接变化原容器 | 不发生路由跳转,需要在一个组件中实现展开及关闭两种状态的布局,展开后组件层级不变。| 适用于转场开销小的简单场景,如点开页面无需加载大量数据及组件。 |
23| 新建容器并跨容器迁移组件 | 通过使用NodeController,将组件从一个容器迁移到另一个容器,在开始迁移时,需要根据前后两个布局的位置大小等信息对组件添加位移及缩放,确保迁移开始时组件能够对齐初始布局,避免出现视觉上的跳变现象。之后再添加动画将位移及缩放等属性复位,实现组件从初始布局到目标布局的一镜到底过渡效果。 | 适用于新建对象开销大的场景,如视频直播组件点击转为全屏等。 |
24| 使用geometryTransition共享元素转场 | 利用系统能力,转场前后两个组件调用geometryTransition接口绑定同一id,同时将转场逻辑置于animateTo动画闭包内,这样系统侧会自动为二者添加一镜到底的过渡效果。 | 系统将调整绑定的两个组件的宽高及位置至相同值,并切换二者的透明度,以实现一镜到底过渡效果。因此,为了实现流畅的动画效果,需要确保对绑定geometryTransition的节点添加宽高动画不会有跳变。此方式适用于创建新节点开销小的场景。 |
25
26## 不新建容器并直接变化原容器
27
28该方法不新建容器,通过在已有容器上增删组件触发[transition](../reference/apis-arkui/arkui-ts/ts-transition-animation-component.md),搭配组件[属性动画](./arkts-attribute-animation-apis.md)实现一镜到底效果。
29
30对于同一个容器展开,容器内兄弟组件消失或者出现的场景,可通过对同一个容器展开前后进行宽高位置变化并配置属性动画,对兄弟组件配置出现消失转场动画实现一镜到底效果。基本步骤为:
31
321. 构建需要展开的页面,并通过状态变量构建好普通状态和展开状态的界面。
33
342. 将需要展开的页面展开,通过状态变量控制兄弟组件消失或出现,并通过绑定出现消失转场实现兄弟组件转场效果。
35
36以点击卡片后显示卡片内容详情场景为例:
37
38```ts
39class PostData {
40  // 图片使用Resource资源,需用户自定义
41  avatar: Resource = $r('app.media.flower');
42  name: string = '';
43  message: string = '';
44  images: Resource[] = [];
45}
46
47@Entry
48@Component
49struct Index {
50  @State isExpand: boolean = false;
51  @State @Watch('onItemClicked') selectedIndex: number = -1;
52
53  // 数组中图片均使用Resource资源,需用户自定义
54  private allPostData: PostData[] = [
55    { avatar: $r('app.media.flower'), name: 'Alice', message: '天气晴朗',
56      images: [$r('app.media.spring'), $r('app.media.tree')] },
57    { avatar: $r('app.media.sky'), name: 'Bob', message: '你好世界',
58      images: [$r('app.media.island')] },
59    { avatar: $r('app.media.tree'), name: 'Carl', message: '万物生长',
60      images: [$r('app.media.flower'), $r('app.media.sky'), $r('app.media.spring')] }];
61
62  private onItemClicked(): void {
63    if (this.selectedIndex < 0) {
64      return;
65    }
66    this.getUIContext()?.animateTo({
67      duration: 350,
68      curve: Curve.Friction
69    }, () => {
70      this.isExpand = !this.isExpand;
71    });
72  }
73
74  build() {
75    Column({ space: 20 }) {
76      ForEach(this.allPostData, (postData: PostData, index: number) => {
77        // 当点击了某个post后,会使其余的post消失下树
78        if (!this.isExpand || this.selectedIndex === index) {
79          Column() {
80            Post({ data: postData, selectedIndex: this.selectedIndex, index: index })
81          }
82          .width('100%')
83          // 对出现消失的post添加透明度转场和位移转场效果
84          .transition(TransitionEffect.OPACITY
85            .combine(TransitionEffect.translate({ y: index < this.selectedIndex ? -250 : 250 }))
86            .animation({ duration: 350, curve: Curve.Friction}))
87        }
88      }, (postData: PostData, index: number) => index.toString())
89    }
90    .size({ width: '100%', height: '100%' })
91    .backgroundColor('#40808080')
92  }
93}
94
95@Component
96export default struct  Post {
97  @Link selectedIndex: number;
98
99  @Prop data: PostData;
100  @Prop index: number;
101
102  @State itemHeight: number = 250;
103  @State isExpand: boolean = false;
104  @State expandImageSize: number = 100;
105  @State avatarSize: number = 50;
106
107  build() {
108    Column({ space: 20 }) {
109      Row({ space: 10 }) {
110        Image(this.data.avatar)
111          .size({ width: this.avatarSize, height: this.avatarSize })
112          .borderRadius(this.avatarSize / 2)
113          .clip(true)
114
115        Text(this.data.name)
116      }
117      .justifyContent(FlexAlign.Start)
118
119      Text(this.data.message)
120
121      Row({ space: 15 }) {
122        ForEach(this.data.images, (imageResource: Resource, index: number) => {
123          Image(imageResource)
124            .size({ width: this.expandImageSize, height: this.expandImageSize })
125        }, (imageResource: Resource, index: number) => index.toString())
126      }
127
128      // 展开态下组件增加的内容
129      if (this.isExpand) {
130        Column() {
131          Text('评论区')
132            // 对评论区文本添加出现消失转场效果
133            .transition( TransitionEffect.OPACITY
134              .animation({ duration: 350, curve: Curve.Friction }))
135            .padding({ top: 10 })
136        }
137        .transition(TransitionEffect.asymmetric(
138          TransitionEffect.opacity(0.99)
139            .animation({ duration: 350, curve: Curve.Friction }),
140          TransitionEffect.OPACITY.animation({ duration: 0 })
141        ))
142        .size({ width: '100%'})
143      }
144    }
145    .backgroundColor(Color.White)
146    .size({ width: '100%', height: this.itemHeight })
147    .alignItems(HorizontalAlign.Start)
148    .padding({ left: 10, top: 10 })
149    .onClick(() => {
150      this.selectedIndex = -1;
151      this.selectedIndex = this.index;
152      this.getUIContext()?.animateTo({
153        duration: 350,
154        curve: Curve.Friction
155      }, () => {
156        // 对展开的post做宽高动画,并对头像尺寸和图片尺寸加动画
157        this.isExpand = !this.isExpand;
158        this.itemHeight = this.isExpand ? 780 : 250;
159        this.avatarSize = this.isExpand ? 75: 50;
160        this.expandImageSize = (this.isExpand && this.data.images.length > 0)
161          ? (360 - (this.data.images.length + 1) * 15) / this.data.images.length : 100;
162      })
163    })
164  }
165}
166```
167
168![zh-cn_image_0000001600653160](figures/zh-cn_image_0000001600653160.gif)
169
170## 新建容器并跨容器迁移组件
171
172通过[NodeContainer](../reference/apis-arkui/arkui-ts/ts-basic-components-nodecontainer.md)[自定义占位节点](arkts-user-defined-place-holder.md),利用[NodeController](../reference/apis-arkui/js-apis-arkui-nodeController.md)实现组件的跨节点迁移,配合属性动画给组件的迁移过程赋予一镜到底效果。这种一镜到底的实现方式可以结合多种转场方式使用,如导航转场([Navigation](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md))、半模态转场([bindSheet](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#bindsheet))等。
173
174### 结合Stack使用
175
176可以利用Stack内后定义组件在最上方的特性控制组件在跨节点迁移后位z序最高,以展开收起卡片的场景为例,实现步骤为:
177
178- 展开卡片时,获取节点A的位置信息,将其中的组件迁移到与节点A位置一致的节点B处,节点B的层级高于节点A。
179
180- 对节点B添加属性动画,使之展开并运动到展开后的位置,完成一镜到底的动画效果。
181
182- 收起卡片时,对节点B添加属性动画,使之收起并运动到收起时的位置,即节点A的位置,实现一镜到底的动画效果。
183
184- 在动画结束时利用回调将节点B中的组件迁移回节点A处。
185
186```ts
187// Index.ets
188import { createPostNode, getPostNode, PostNode } from "./PostNode";
189import { componentUtils, curves, UIContext } from '@kit.ArkUI';
190
191@Entry
192@Component
193struct Index {
194  // 新建一镜到底动画类
195  private uiContext: UIContext = this.getUIContext();
196  @State animationProperties: AnimationProperties = new AnimationProperties(this.uiContext);
197  private listArray: Array<number> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
198
199  build() {
200    // 卡片折叠态,展开态的共同父组件
201    Stack() {
202      List({ space: 20 }) {
203        ForEach(this.listArray, (item: number) => {
204          ListItem() {
205            // 卡片折叠态
206            PostItem({ index: item, animationProperties: this.animationProperties })
207          }
208        })
209      }
210      .clip(false)
211      .alignListItem(ListItemAlign.Center)
212
213      if (this.animationProperties.isExpandPageShow) {
214        // 卡片展开态
215        ExpandPage({ animationProperties: this.animationProperties })
216      }
217    }
218    .key('rootStack')
219    .enabled(this.animationProperties.isEnabled)
220  }
221}
222
223@Component
224struct PostItem {
225  @Prop index: number
226  @Link animationProperties: AnimationProperties;
227  @State nodeController: PostNode | undefined = undefined;
228  // 折叠时详细内容隐藏
229  private showDetailContent: boolean = false;
230
231  aboutToAppear(): void {
232    this.nodeController = createPostNode(this.getUIContext(), this.index.toString(), this.showDetailContent);
233    if (this.nodeController != undefined) {
234      // 设置回调,当卡片从展开态回到折叠态时触发
235      this.nodeController.setCallback(this.resetNode.bind(this));
236    }
237  }
238
239  resetNode() {
240    this.nodeController = getPostNode(this.index.toString());
241  }
242
243  build() {
244    Stack() {
245      NodeContainer(this.nodeController)
246    }
247    .width('100%')
248    .height(100)
249    .key(this.index.toString())
250    .onClick(() => {
251      if (this.nodeController != undefined) {
252        // 卡片从折叠态节点下树
253        this.nodeController.onRemove();
254      }
255      // 触发卡片从折叠到展开态的动画
256      this.animationProperties.expandAnimation(this.index);
257    })
258  }
259}
260
261@Component
262struct ExpandPage {
263  @Link animationProperties: AnimationProperties;
264  @State nodeController: PostNode | undefined = undefined;
265  // 展开时详细内容出现
266  private showDetailContent: boolean = true;
267
268  aboutToAppear(): void {
269    // 获取对应序号的卡片组件
270    this.nodeController = getPostNode(this.animationProperties.curIndex.toString())
271    // 更新为详细内容出现
272    this.nodeController?.update(this.animationProperties.curIndex.toString(), this.showDetailContent)
273  }
274
275  build() {
276    Stack() {
277      NodeContainer(this.nodeController)
278    }
279    .width('100%')
280    .height(this.animationProperties.changedHeight ? '100%' : 100)
281    .translate({ x: this.animationProperties.translateX, y: this.animationProperties.translateY })
282    .position({ x: this.animationProperties.positionX, y: this.animationProperties.positionY })
283    .onClick(() => {
284      this.getUIContext()?.animateTo({ curve: curves.springMotion(0.6, 0.9),
285        onFinish: () => {
286          if (this.nodeController != undefined) {
287            // 执行回调,折叠态节点获取卡片组件
288            this.nodeController.callCallback();
289            // 当前展开态节点的卡片组件下树
290            this.nodeController.onRemove();
291          }
292          // 卡片展开态节点下树
293          this.animationProperties.isExpandPageShow = false;
294          this.animationProperties.isEnabled = true;
295        }
296      }, () => {
297        // 卡片从展开态回到折叠态
298        this.animationProperties.isEnabled = false;
299        this.animationProperties.translateX = 0;
300        this.animationProperties.translateY = 0;
301        this.animationProperties.changedHeight = false;
302        // 更新为详细内容消失
303        this.nodeController?.update(this.animationProperties.curIndex.toString(), false);
304      })
305    })
306  }
307}
308
309class RectInfo {
310  left: number = 0;
311  top: number = 0;
312  right: number = 0;
313  bottom: number = 0;
314  width: number = 0;
315  height: number = 0;
316}
317
318// 封装的一镜到底动画类
319@Observed
320class AnimationProperties {
321  public isExpandPageShow: boolean = false;
322  // 控制组件是否响应点击事件
323  public isEnabled: boolean = true;
324  // 展开卡片的序号
325  public curIndex: number = -1;
326  public translateX: number = 0;
327  public translateY: number = 0;
328  public positionX: number = 0;
329  public positionY: number = 0;
330  public changedHeight: boolean = false;
331  private calculatedTranslateX: number = 0;
332  private calculatedTranslateY: number = 0;
333  // 设置卡片展开后相对父组件的位置
334  private expandTranslateX: number = 0;
335  private expandTranslateY: number = 0;
336  private uiContext: UIContext;
337
338  constructor(uiContext: UIContext) {
339    this.uiContext = uiContext
340  }
341
342  public expandAnimation(index: number): void {
343    // 记录展开态卡片的序号
344    if (index != undefined) {
345      this.curIndex = index;
346    }
347    // 计算折叠态卡片相对父组件的位置
348    this.calculateData(index.toString());
349    // 展开态卡片上树
350    this.isExpandPageShow = true;
351    // 卡片展开的属性动画
352    this.uiContext?.animateTo({ curve: curves.springMotion(0.6, 0.9)
353    }, () => {
354      this.translateX = this.calculatedTranslateX;
355      this.translateY = this.calculatedTranslateY;
356      this.changedHeight = true;
357    })
358  }
359
360  // 获取需要跨节点迁移的组件的位置,及迁移前后节点的公共父节点的位置,用以计算做动画组件的动画参数
361  public calculateData(key: string): void {
362    let clickedImageInfo = this.getRectInfoById(this.uiContext, key);
363    let rootStackInfo = this.getRectInfoById(this.uiContext, 'rootStack');
364    this.positionX = this.uiContext.px2vp(clickedImageInfo.left - rootStackInfo.left);
365    this.positionY = this.uiContext.px2vp(clickedImageInfo.top - rootStackInfo.top);
366    this.calculatedTranslateX = this.uiContext.px2vp(rootStackInfo.left - clickedImageInfo.left) + this.expandTranslateX;
367    this.calculatedTranslateY = this.uiContext.px2vp(rootStackInfo.top - clickedImageInfo.top) + this.expandTranslateY;
368  }
369
370  // 根据组件的id获取组件的位置信息
371  private getRectInfoById(context: UIContext, id: string): RectInfo {
372    let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id);
373
374    if (!componentInfo) {
375      throw Error('object is empty');
376    }
377
378    let rstRect: RectInfo = new RectInfo();
379    const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2;
380    const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2;
381    rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap;
382    rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap;
383    rstRect.right =
384    componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap;
385    rstRect.bottom =
386    componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap;
387    rstRect.width = rstRect.right - rstRect.left;
388    rstRect.height = rstRect.bottom - rstRect.top;
389
390    return {
391      left: rstRect.left,
392      right: rstRect.right,
393      top: rstRect.top,
394      bottom: rstRect.bottom,
395      width: rstRect.width,
396      height: rstRect.height
397    }
398  }
399}
400```
401
402```ts
403// PostNode.ets
404// 跨容器迁移能力
405import { UIContext } from '@ohos.arkui.UIContext';
406import { NodeController, BuilderNode, FrameNode } from '@ohos.arkui.node';
407import { curves } from '@kit.ArkUI';
408
409class Data {
410  item: string | null = null
411  isExpand: boolean = false
412}
413
414@Builder
415function PostBuilder(data: Data) {
416  // 跨容器迁移组件置于@Builder内
417  Column() {
418      Row() {
419        Row()
420          .backgroundColor(Color.Pink)
421          .borderRadius(20)
422          .width(80)
423          .height(80)
424
425        Column() {
426          Text('点击展开 Item ' + data.item)
427            .fontSize(20)
428          Text('共享元素转场')
429            .fontSize(12)
430            .fontColor(0x909399)
431        }
432        .alignItems(HorizontalAlign.Start)
433        .justifyContent(FlexAlign.SpaceAround)
434        .margin({ left: 10 })
435        .height(80)
436      }
437      .width('90%')
438      .height(100)
439      // 展开后显示细节内容
440      if (data.isExpand) {
441        Row() {
442          Text('展开态')
443            .fontSize(28)
444            .fontColor(0x909399)
445            .textAlign(TextAlign.Center)
446            .transition(TransitionEffect.OPACITY.animation({ curve: curves.springMotion(0.6, 0.9) }))
447        }
448        .width('90%')
449        .justifyContent(FlexAlign.Center)
450      }
451    }
452    .width('90%')
453    .height('100%')
454    .alignItems(HorizontalAlign.Center)
455    .borderRadius(10)
456    .margin({ top: 15 })
457    .backgroundColor(Color.White)
458    .shadow({
459      radius: 20,
460      color: 0x909399,
461      offsetX: 20,
462      offsetY: 10
463    })
464}
465
466class __InternalValue__ {
467  flag:boolean =false;
468};
469
470export class PostNode extends NodeController {
471  private node: BuilderNode<Data[]> | null = null;
472  private isRemove: __InternalValue__ = new __InternalValue__();
473  private callback: Function | undefined = undefined
474  private data: Data | null = null
475
476  makeNode(uiContext: UIContext): FrameNode | null {
477    if(this.isRemove.flag == true){
478      return null;
479    }
480    if (this.node != null) {
481      return this.node.getFrameNode();
482    }
483
484    return null;
485  }
486
487  init(uiContext: UIContext, id: string, isExpand: boolean) {
488    if (this.node != null) {
489      return;
490    }
491    // 创建节点,需要uiContext
492    this.node = new BuilderNode(uiContext)
493    // 创建离线组件
494    this.data = { item: id, isExpand: isExpand }
495    this.node.build(wrapBuilder<Data[]>(PostBuilder), this.data)
496  }
497
498  update(id: string, isExpand: boolean) {
499    if (this.node !== null) {
500      // 调用update进行更新。
501      this.data = { item: id, isExpand: isExpand }
502      this.node.update(this.data);
503    }
504  }
505
506  setCallback(callback: Function | undefined) {
507    this.callback = callback
508  }
509
510  callCallback() {
511    if (this.callback != undefined) {
512      this.callback();
513    }
514  }
515
516  onRemove(){
517    this.isRemove.flag = true;
518    // 组件迁移出节点时触发重建
519    this.rebuild();
520    this.isRemove.flag = false;
521  }
522}
523
524let gNodeMap: Map<string, PostNode | undefined> = new Map();
525
526export const createPostNode =
527  (uiContext: UIContext, id: string, isExpand: boolean): PostNode | undefined => {
528    let node = new PostNode();
529    node.init(uiContext, id, isExpand);
530    gNodeMap.set(id, node);
531    return node;
532  }
533
534export const getPostNode = (id: string): PostNode | undefined => {
535  if (!gNodeMap.has(id)) {
536    return undefined
537  }
538  return gNodeMap.get(id);
539}
540
541export const deleteNode = (id: string) => {
542  gNodeMap.delete(id)
543}
544```
545
546![zh_cn_image_sharedElementsNodeTransfer](figures/zh-cn_image_sharedElementsNodeTransfer.gif)
547
548### 结合Navigation使用
549
550可以利用[Navigation](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md)的自定义导航转场动画能力([customNavContentTransition](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md#customnavcontenttransition11),可参考Navigation[示例3](../reference/apis-arkui/arkui-ts/ts-basic-components-navigation.md#示例3设置可交互转场动画))实现一镜到底动效。共享元素转场期间,组件由消失页面迁移至出现页面。
551
552以展开收起缩略图的场景为例,实现步骤为:
553
554- 通过customNavContentTransition配置PageOne与PageTwo的自定义导航转场动画。
555
556- 自定义的共享元素转场效果由属性动画实现,具体实现方式为抓取页面内组件相对窗口的位置信息从而正确匹配组件在PageOne与PageTwo的位置、缩放等,即动画开始和结束的属性信息。
557
558- 点击缩略图后共享元素组件从PageOne被迁移至PageTwo,随后触发由PageOne至PageTwo的自定义转场动画,即PageTwo的共享元素组件从原来的缩略图状态做动画到全屏状态。
559
560- 由全屏状态返回到缩略图时,触发由PageTwo至PageOne的自定义转场动画,即PageTwo的共享元素组件从全屏状态做动画到原PageOne的缩略图状态,转场结束后共享元素组件从PageTwo被迁移回PageOne。
561
562```
563├──entry/src/main/ets                 // 代码区
564│  ├──CustomTransition
565│  │  ├──AnimationProperties.ets      // 一镜到底转场动画封装
566│  │  └──CustomNavigationUtils.ets    // Navigation自定义转场动画配置
567│  ├──entryability
568│  │  └──EntryAbility.ets             // 程序入口类
569│  ├──NodeContainer
570│  │  └──CustomComponent.ets          // 自定义占位节点
571│  ├──pages
572│  │  ├──Index.ets                    // 导航页面
573│  │  ├──PageOne.ets                  // 缩略图页面
574│  │  └──PageTwo.ets                  // 全屏展开页面
575│  └──utils
576│     ├──ComponentAttrUtils.ets       // 组件位置获取
577│     └──WindowUtils.ets              // 窗口信息
578└──entry/src/main/resources           // 资源文件
579```
580
581```ts
582// Index.ets
583import { AnimateCallback, CustomTransition } from '../CustomTransition/CustomNavigationUtils';
584
585const TAG: string = 'Index';
586
587@Entry
588@Component
589struct Index {
590  private pageInfos: NavPathStack = new NavPathStack();
591  // 允许进行自定义转场的页面名称
592  private allowedCustomTransitionFromPageName: string[] = ['PageOne'];
593  private allowedCustomTransitionToPageName: string[] = ['PageTwo'];
594
595  aboutToAppear(): void {
596    this.pageInfos.pushPath({ name: 'PageOne' });
597  }
598
599  private isCustomTransitionEnabled(fromName: string, toName: string): boolean {
600    // 点击和返回均需要进行自定义转场,因此需要分别判断
601    if ((this.allowedCustomTransitionFromPageName.includes(fromName)
602      && this.allowedCustomTransitionToPageName.includes(toName))
603      || (this.allowedCustomTransitionFromPageName.includes(toName)
604        && this.allowedCustomTransitionToPageName.includes(fromName))) {
605      return true;
606    }
607    return false;
608  }
609
610  build() {
611    Navigation(this.pageInfos)
612      .hideNavBar(true)
613      .customNavContentTransition((from: NavContentInfo, to: NavContentInfo, operation: NavigationOperation) => {
614        if ((!from || !to) || (!from.name || !to.name)) {
615          return undefined;
616        }
617
618        // 通过from和to的name对自定义转场路由进行管控
619        if (!this.isCustomTransitionEnabled(from.name, to.name)) {
620          return undefined;
621        }
622
623        // 需要对转场页面是否注册了animation进行判断,来决定是否进行自定义转场
624        let fromParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(from.index);
625        let toParam: AnimateCallback = CustomTransition.getInstance().getAnimateParam(to.index);
626        if (!fromParam.animation || !toParam.animation) {
627          return undefined;
628        }
629
630        // 一切判断完成后,构造customAnimation给系统侧调用,执行自定义转场动画
631        let customAnimation: NavigationAnimatedTransition = {
632          onTransitionEnd: (isSuccess: boolean) => {
633            console.info(TAG, `current transition result is ${isSuccess}`);
634          },
635          timeout: 2000,
636          transition: (transitionProxy: NavigationTransitionProxy) => {
637            console.info(TAG, 'trigger transition callback');
638            if (fromParam.animation) {
639              fromParam.animation(operation == NavigationOperation.PUSH, true, transitionProxy);
640            }
641            if (toParam.animation) {
642              toParam.animation(operation == NavigationOperation.PUSH, false, transitionProxy);
643            }
644          }
645        };
646        return customAnimation;
647      })
648  }
649}
650```
651
652```ts
653// PageOne.ets
654import { CustomTransition } from '../CustomTransition/CustomNavigationUtils';
655import { MyNodeController, createMyNode, getMyNode } from '../NodeContainer/CustomComponent';
656import { ComponentAttrUtils, RectInfoInPx } from '../utils/ComponentAttrUtils';
657import { WindowUtils } from '../utils/WindowUtils';
658
659@Builder
660export function PageOneBuilder() {
661  PageOne();
662}
663
664@Component
665export struct PageOne {
666  private pageInfos: NavPathStack = new NavPathStack();
667  private pageId: number = -1;
668  @State myNodeController: MyNodeController | undefined = new MyNodeController(false);
669
670  aboutToAppear(): void {
671    let node = getMyNode();
672    if (node == undefined) {
673      // 新建自定义节点
674      createMyNode(this.getUIContext());
675    }
676    this.myNodeController = getMyNode();
677  }
678
679  private doFinishTransition(): void {
680    // PageTwo结束转场时将节点从PageTwo迁移回PageOne
681    this.myNodeController = getMyNode();
682  }
683
684  private registerCustomTransition(): void {
685    // 注册自定义动画协议
686    CustomTransition.getInstance().registerNavParam(this.pageId,
687      (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => {}, 500);
688  }
689
690  private onCardClicked(): void {
691    let cardItemInfo: RectInfoInPx =
692      ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), 'card');
693    let param: Record<string, Object> = {};
694    param['cardItemInfo'] = cardItemInfo;
695    param['doDefaultTransition'] = (myController: MyNodeController) => {
696      this.doFinishTransition()
697    };
698    this.pageInfos.pushPath({ name: 'PageTwo', param: param });
699    // 自定义节点从PageOne下树
700    if (this.myNodeController != undefined) {
701      (this.myNodeController as MyNodeController).onRemove();
702    }
703  }
704
705  build() {
706    NavDestination() {
707      Stack() {
708        Column({ space: 20 }) {
709          Row({ space: 10 }) {
710            // 图片使用Resource资源,需用户自定义
711            Image($r("app.media.avatar"))
712              .size({ width: 50, height: 50 })
713              .borderRadius(25)
714              .clip(true)
715
716            Text('Alice')
717          }
718          .justifyContent(FlexAlign.Start)
719
720          Text('你好世界')
721
722          NodeContainer(this.myNodeController)
723            .size({ width: 320, height: 250 })
724            .onClick(() => {
725              this.onCardClicked()
726            })
727        }
728        .alignItems(HorizontalAlign.Start)
729        .margin(30)
730      }
731    }
732    .onReady((context: NavDestinationContext) => {
733      this.pageInfos = context.pathStack;
734      this.pageId = this.pageInfos.getAllPathName().length - 1;
735      this.registerCustomTransition();
736    })
737    .onDisAppear(() => {
738      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
739      // 自定义节点从PageOne下树
740      if (this.myNodeController != undefined) {
741        (this.myNodeController as MyNodeController).onRemove();
742      }
743    })
744  }
745}
746```
747
748```ts
749// PageTwo.ets
750import { CustomTransition } from '../CustomTransition/CustomNavigationUtils';
751import { AnimationProperties } from '../CustomTransition/AnimationProperties';
752import { RectInfoInPx } from '../utils/ComponentAttrUtils';
753import { getMyNode, MyNodeController } from '../NodeContainer/CustomComponent';
754
755@Builder
756export function PageTwoBuilder() {
757  PageTwo();
758}
759
760@Component
761export struct PageTwo {
762  @State pageInfos: NavPathStack = new NavPathStack();
763  @State animationProperties: AnimationProperties = new AnimationProperties(this.getUIContext());
764  @State myNodeController: MyNodeController | undefined = new MyNodeController(false);
765
766  private pageId: number = -1;
767
768  private shouldDoDefaultTransition: boolean = false;
769  private prePageDoFinishTransition: () => void = () => {};
770  private cardItemInfo: RectInfoInPx = new RectInfoInPx();
771
772  @StorageProp('windowSizeChanged') @Watch('unRegisterNavParam') windowSizeChangedTime: number = 0;
773  @StorageProp('onConfigurationUpdate') @Watch('unRegisterNavParam') onConfigurationUpdateTime: number = 0;
774
775  aboutToAppear(): void {
776    // 迁移自定义节点至当前页面
777    this.myNodeController = getMyNode();
778  }
779
780  private unRegisterNavParam(): void {
781    this.shouldDoDefaultTransition = true;
782  }
783
784  private onBackPressed(): boolean {
785    if (this.shouldDoDefaultTransition) {
786      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
787      this.pageInfos.pop();
788      this.prePageDoFinishTransition();
789      this.shouldDoDefaultTransition = false;
790      return true;
791    }
792    this.pageInfos.pop();
793    return true;
794  }
795
796  build() {
797    NavDestination() {
798      // Stack需要设置alignContent为TopStart,否则在高度变化过程中,截图和内容都会随高度重新布局位置
799      Stack({ alignContent: Alignment.TopStart }) {
800        Stack({ alignContent: Alignment.TopStart }) {
801          Column({space: 20}) {
802            NodeContainer(this.myNodeController)
803            if (this.animationProperties.showDetailContent)
804              Text('展开态内容')
805                .fontSize(20)
806                .transition(TransitionEffect.OPACITY)
807                .margin(30)
808          }
809          .alignItems(HorizontalAlign.Start)
810        }
811        .position({ y: this.animationProperties.positionValue })
812      }
813      .scale({ x: this.animationProperties.scaleValue, y: this.animationProperties.scaleValue })
814      .translate({ x: this.animationProperties.translateX, y: this.animationProperties.translateY })
815      .width(this.animationProperties.clipWidth)
816      .height(this.animationProperties.clipHeight)
817      .borderRadius(this.animationProperties.radius)
818      // expandSafeArea使得Stack做沉浸式效果,向上扩到状态栏,向下扩到导航条
819      .expandSafeArea([SafeAreaType.SYSTEM])
820      // 对高度进行裁切
821      .clip(true)
822    }
823    .backgroundColor(this.animationProperties.navDestinationBgColor)
824    .hideTitleBar(true)
825    .onReady((context: NavDestinationContext) => {
826      this.pageInfos = context.pathStack;
827      this.pageId = this.pageInfos.getAllPathName().length - 1;
828      let param = context.pathInfo?.param as Record<string, Object>;
829      this.prePageDoFinishTransition = param['doDefaultTransition'] as () => void;
830      this.cardItemInfo = param['cardItemInfo'] as RectInfoInPx;
831      CustomTransition.getInstance().registerNavParam(this.pageId,
832        (isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => {
833          this.animationProperties.doAnimation(
834            this.cardItemInfo, isPush, isExit, transitionProxy, 0,
835            this.prePageDoFinishTransition, this.myNodeController);
836        }, 500);
837    })
838    .onBackPressed(() => {
839      return this.onBackPressed();
840    })
841    .onDisAppear(() => {
842      CustomTransition.getInstance().unRegisterNavParam(this.pageId);
843    })
844  }
845}
846```
847
848```ts
849// CustomNavigationUtils.ets
850// 配置Navigation自定义转场动画
851export interface AnimateCallback {
852  animation: ((isPush: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void | undefined)
853    | undefined;
854  timeout: (number | undefined) | undefined;
855}
856
857const customTransitionMap: Map<number, AnimateCallback> = new Map();
858
859export class CustomTransition {
860  private constructor() {};
861
862  static delegate = new CustomTransition();
863
864  static getInstance() {
865    return CustomTransition.delegate;
866  }
867
868  // 注册页面的动画回调,name是注册页面的动画的回调
869  // animationCallback是需要执行的动画内容,timeout是转场结束的超时时间
870  registerNavParam(
871    name: number,
872    animationCallback: (operation: boolean, isExit: boolean, transitionProxy: NavigationTransitionProxy) => void,
873    timeout: number): void {
874    if (customTransitionMap.has(name)) {
875      let param = customTransitionMap.get(name);
876      if (param != undefined) {
877        param.animation = animationCallback;
878        param.timeout = timeout;
879        return;
880      }
881    }
882    let params: AnimateCallback = { timeout: timeout, animation: animationCallback };
883    customTransitionMap.set(name, params);
884  }
885
886  unRegisterNavParam(name: number): void {
887    customTransitionMap.delete(name);
888  }
889
890  getAnimateParam(name: number): AnimateCallback {
891    let result: AnimateCallback = {
892      animation: customTransitionMap.get(name)?.animation,
893      timeout: customTransitionMap.get(name)?.timeout,
894    };
895    return result;
896  }
897}
898```
899
900```ts
901// 工程配置文件module.json5中配置 {"routerMap": "$profile:route_map"}
902// route_map.json
903{
904  "routerMap": [
905    {
906      "name": "PageOne",
907      "pageSourceFile": "src/main/ets/pages/PageOne.ets",
908      "buildFunction": "PageOneBuilder"
909    },
910    {
911      "name": "PageTwo",
912      "pageSourceFile": "src/main/ets/pages/PageTwo.ets",
913      "buildFunction": "PageTwoBuilder"
914    }
915  ]
916}
917```
918
919```ts
920// AnimationProperties.ets
921// 一镜到底转场动画封装
922import { curves, UIContext } from '@kit.ArkUI';
923import { RectInfoInPx } from '../utils/ComponentAttrUtils';
924import { WindowUtils } from '../utils/WindowUtils';
925import { MyNodeController } from '../NodeContainer/CustomComponent';
926
927const TAG: string = 'AnimationProperties';
928
929const DEVICE_BORDER_RADIUS: number = 34;
930
931// 将自定义一镜到底转场动画进行封装,其他界面也需要做自定义一镜到底转场的话,可以直接复用,减少工作量
932@Observed
933export class AnimationProperties {
934  public navDestinationBgColor: ResourceColor = Color.Transparent;
935  public translateX: number = 0;
936  public translateY: number = 0;
937  public scaleValue: number = 1;
938  public clipWidth: Dimension = 0;
939  public clipHeight: Dimension = 0;
940  public radius: number = 0;
941  public positionValue: number = 0;
942  public showDetailContent: boolean = false;
943  private uiContext: UIContext;
944
945  constructor(uiContext: UIContext) {
946    this.uiContext = uiContext
947  }
948
949  public doAnimation(cardItemInfo_px: RectInfoInPx, isPush: boolean, isExit: boolean,
950                     transitionProxy: NavigationTransitionProxy, extraTranslateValue: number, prePageOnFinish: (index: MyNodeController) => void, myNodeController: MyNodeController | undefined): void {
951    // 首先计算卡片的宽高与窗口宽高的比例
952    let widthScaleRatio = cardItemInfo_px.width / WindowUtils.windowWidth_px;
953    let heightScaleRatio = cardItemInfo_px.height / WindowUtils.windowHeight_px;
954    let isUseWidthScale = widthScaleRatio > heightScaleRatio;
955    let initScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio;
956
957    let initTranslateX: number = 0;
958    let initTranslateY: number = 0;
959    let initClipWidth: Dimension = 0;
960    let initClipHeight: Dimension = 0;
961    // 使得PageTwo卡片向上扩到状态栏
962    let initPositionValue: number = -this.uiContext.px2vp(WindowUtils.topAvoidAreaHeight_px + extraTranslateValue);
963
964    if (isUseWidthScale) {
965      initTranslateX = this.uiContext.px2vp(cardItemInfo_px.left - (WindowUtils.windowWidth_px - cardItemInfo_px.width) / 2);
966      initClipWidth = '100%';
967      initClipHeight = this.uiContext.px2vp((cardItemInfo_px.height) / initScale);
968      initTranslateY = this.uiContext.px2vp(cardItemInfo_px.top - ((this.uiContext.vp2px(initClipHeight) - this.uiContext.vp2px(initClipHeight) * initScale) / 2));
969    } else {
970      initTranslateY = this.uiContext.px2vp(cardItemInfo_px.top - (WindowUtils.windowHeight_px - cardItemInfo_px.height) / 2);
971      initClipHeight = '100%';
972      initClipWidth = this.uiContext.px2vp((cardItemInfo_px.width) / initScale);
973      initTranslateX = this.uiContext.px2vp(cardItemInfo_px.left - (WindowUtils.windowWidth_px / 2 - cardItemInfo_px.width / 2));
974    }
975
976    // 转场动画开始前通过计算scale、translate、position和clip height & width,确定节点迁移前后位置一致
977    console.info(TAG, 'initScale: ' + initScale + ' initTranslateX ' + initTranslateX +
978    ' initTranslateY ' + initTranslateY + ' initClipWidth ' + initClipWidth +
979    ' initClipHeight ' + initClipHeight + ' initPositionValue ' + initPositionValue);
980    // 转场至新页面
981    if (isPush && !isExit) {
982      this.scaleValue = initScale;
983      this.translateX = initTranslateX;
984      this.clipWidth = initClipWidth;
985      this.clipHeight = initClipHeight;
986      this.translateY = initTranslateY;
987      this.positionValue = initPositionValue;
988
989      this.uiContext?.animateTo({
990        curve: curves.interpolatingSpring(0, 1, 328, 36),
991        onFinish: () => {
992          if (transitionProxy) {
993            transitionProxy.finishTransition();
994          }
995        }
996      }, () => {
997        this.scaleValue = 1.0;
998        this.translateX = 0;
999        this.translateY = 0;
1000        this.clipWidth = '100%';
1001        this.clipHeight = '100%';
1002        // 页面圆角与系统圆角一致
1003        this.radius = DEVICE_BORDER_RADIUS;
1004        this.showDetailContent = true;
1005      })
1006
1007      this.uiContext?.animateTo({
1008        duration: 100,
1009        curve: Curve.Sharp,
1010      }, () => {
1011        // 页面由透明逐渐变为设置背景色
1012        this.navDestinationBgColor = '#00ffffff';
1013      })
1014
1015      // 返回旧页面
1016    } else if (!isPush && isExit) {
1017
1018      this.uiContext?.animateTo({
1019        duration: 350,
1020        curve: Curve.EaseInOut,
1021        onFinish: () => {
1022          if (transitionProxy) {
1023            transitionProxy.finishTransition();
1024          }
1025          prePageOnFinish(myNodeController);
1026          // 自定义节点从PageTwo下树
1027          if (myNodeController != undefined) {
1028            (myNodeController as MyNodeController).onRemove();
1029          }
1030        }
1031      }, () => {
1032        this.scaleValue = initScale;
1033        this.translateX = initTranslateX;
1034        this.translateY = initTranslateY;
1035        this.radius = 0;
1036        this.clipWidth = initClipWidth;
1037        this.clipHeight = initClipHeight;
1038        this.showDetailContent = false;
1039      })
1040
1041      this.uiContext?.animateTo({
1042        duration: 200,
1043        delay: 150,
1044        curve: Curve.Friction,
1045      }, () => {
1046        this.navDestinationBgColor = Color.Transparent;
1047      })
1048    }
1049  }
1050}
1051```
1052
1053```ts
1054// ComponentAttrUtils.ets
1055// 获取组件相对窗口的位置
1056import { componentUtils, UIContext } from '@kit.ArkUI';
1057import { JSON } from '@kit.ArkTS';
1058
1059export class ComponentAttrUtils {
1060  // 根据组件的id获取组件的位置信息
1061  public static getRectInfoById(context: UIContext, id: string): RectInfoInPx {
1062    if (!context || !id) {
1063      throw Error('object is empty');
1064    }
1065    let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id);
1066
1067    if (!componentInfo) {
1068      throw Error('object is empty');
1069    }
1070
1071    let rstRect: RectInfoInPx = new RectInfoInPx();
1072    const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2;
1073    const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2;
1074    rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap;
1075    rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap;
1076    rstRect.right =
1077      componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap;
1078    rstRect.bottom =
1079      componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap;
1080    rstRect.width = rstRect.right - rstRect.left;
1081    rstRect.height = rstRect.bottom - rstRect.top;
1082    return {
1083      left: rstRect.left,
1084      right: rstRect.right,
1085      top: rstRect.top,
1086      bottom: rstRect.bottom,
1087      width: rstRect.width,
1088      height: rstRect.height
1089    }
1090  }
1091}
1092
1093export class RectInfoInPx {
1094  left: number = 0;
1095  top: number = 0;
1096  right: number = 0;
1097  bottom: number = 0;
1098  width: number = 0;
1099  height: number = 0;
1100}
1101
1102export class RectJson {
1103  $rect: Array<number> = [];
1104}
1105```
1106
1107```ts
1108// WindowUtils.ets
1109// 窗口信息
1110import { window } from '@kit.ArkUI';
1111
1112export class WindowUtils {
1113  public static window: window.Window;
1114  public static windowWidth_px: number;
1115  public static windowHeight_px: number;
1116  public static topAvoidAreaHeight_px: number;
1117  public static navigationIndicatorHeight_px: number;
1118}
1119```
1120
1121```ts
1122// EntryAbility.ets
1123// 程序入口处的onWindowStageCreate增加对窗口宽高等的抓取
1124
1125import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
1126import { hilog } from '@kit.PerformanceAnalysisKit';
1127import { display, window } from '@kit.ArkUI';
1128import { WindowUtils } from '../utils/WindowUtils';
1129
1130const TAG: string = 'EntryAbility';
1131
1132export default class EntryAbility extends UIAbility {
1133  private currentBreakPoint: string = '';
1134
1135  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
1136    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
1137  }
1138
1139  onDestroy(): void {
1140    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy');
1141  }
1142
1143  onWindowStageCreate(windowStage: window.WindowStage): void {
1144    // Main window is created, set main page for this ability
1145    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
1146
1147    // 获取窗口宽高
1148    WindowUtils.window = windowStage.getMainWindowSync();
1149    WindowUtils.windowWidth_px = WindowUtils.window.getWindowProperties().windowRect.width;
1150    WindowUtils.windowHeight_px = WindowUtils.window.getWindowProperties().windowRect.height;
1151
1152    this.updateBreakpoint(WindowUtils.windowWidth_px);
1153
1154    // 获取上方避让区(状态栏等)高度
1155    let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
1156    WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height;
1157
1158    // 获取导航条高度
1159    let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
1160    WindowUtils.navigationIndicatorHeight_px = navigationArea.bottomRect.height;
1161
1162    hilog.info(0x0000, TAG, 'the width is ' + WindowUtils.windowWidth_px + '  ' + WindowUtils.windowHeight_px + '  ' +
1163    WindowUtils.topAvoidAreaHeight_px + '  ' + WindowUtils.navigationIndicatorHeight_px);
1164
1165    // 监听窗口尺寸、状态栏高度及导航条高度的变化并更新
1166    try {
1167      WindowUtils.window.on('windowSizeChange', (data) => {
1168        hilog.info(0x0000, TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height);
1169        WindowUtils.windowWidth_px = data.width;
1170        WindowUtils.windowHeight_px = data.height;
1171        this.updateBreakpoint(data.width);
1172        AppStorage.setOrCreate('windowSizeChanged', Date.now())
1173      })
1174
1175      WindowUtils.window.on('avoidAreaChange', (data) => {
1176        if (data.type == window.AvoidAreaType.TYPE_SYSTEM) {
1177          let topRectHeight = data.area.topRect.height;
1178          hilog.info(0x0000, TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight);
1179          WindowUtils.topAvoidAreaHeight_px = topRectHeight;
1180        } else if (data.type == window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
1181          let bottomRectHeight = data.area.bottomRect.height;
1182          hilog.info(0x0000, TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight);
1183          WindowUtils.navigationIndicatorHeight_px = bottomRectHeight;
1184        }
1185      })
1186    } catch (exception) {
1187      hilog.error(0x0000, TAG, `register failed. code: ${exception.code}, message: ${exception.message}`);
1188    }
1189
1190    windowStage.loadContent('pages/Index', (err) => {
1191      if (err.code) {
1192        hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
1193        return;
1194      }
1195      hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
1196    });
1197  }
1198
1199  updateBreakpoint(width: number) {
1200    let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160);
1201    let newBreakPoint: string = '';
1202    if (windowWidthVp < 400) {
1203      newBreakPoint = 'xs';
1204    } else if (windowWidthVp < 600) {
1205      newBreakPoint = 'sm';
1206    } else if (windowWidthVp < 800) {
1207      newBreakPoint = 'md';
1208    } else {
1209      newBreakPoint = 'lg';
1210    }
1211    if (this.currentBreakPoint !== newBreakPoint) {
1212      this.currentBreakPoint = newBreakPoint;
1213      // 使用状态变量记录当前断点值
1214      AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint);
1215    }
1216  }
1217
1218  onWindowStageDestroy(): void {
1219    // Main window is destroyed, release UI related resources
1220    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
1221  }
1222
1223  onForeground(): void {
1224    // Ability has brought to foreground
1225    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground');
1226  }
1227
1228  onBackground(): void {
1229    // Ability has back to background
1230    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground');
1231  }
1232}
1233```
1234
1235```ts
1236// CustomComponent.ets
1237// 自定义占位节点,跨容器迁移能力
1238import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI';
1239
1240@Builder
1241function CardBuilder() {
1242  // 图片使用Resource资源,需用户自定义
1243  Image($r("app.media.card"))
1244    .width('100%')
1245    .id('card')
1246}
1247
1248export class MyNodeController extends NodeController {
1249  private CardNode: BuilderNode<[]> | null = null;
1250  private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(CardBuilder);
1251  private needCreate: boolean = false;
1252  private isRemove: boolean = false;
1253
1254  constructor(create: boolean) {
1255    super();
1256    this.needCreate = create;
1257  }
1258
1259  makeNode(uiContext: UIContext): FrameNode | null {
1260    if(this.isRemove == true){
1261      return null;
1262    }
1263    if (this.needCreate && this.CardNode == null) {
1264      this.CardNode = new BuilderNode(uiContext);
1265      this.CardNode.build(this.wrapBuilder)
1266    }
1267    if (this.CardNode == null) {
1268      return null;
1269    }
1270    return this.CardNode!.getFrameNode()!;
1271  }
1272
1273  getNode(): BuilderNode<[]> | null {
1274    return this.CardNode;
1275  }
1276
1277  setNode(node: BuilderNode<[]> | null) {
1278    this.CardNode = node;
1279    this.rebuild();
1280  }
1281
1282  onRemove() {
1283    this.isRemove = true;
1284    this.rebuild();
1285    this.isRemove = false;
1286  }
1287
1288  init(uiContext: UIContext) {
1289    this.CardNode = new BuilderNode(uiContext);
1290    this.CardNode.build(this.wrapBuilder)
1291  }
1292}
1293
1294let myNode: MyNodeController | undefined;
1295
1296export const createMyNode =
1297  (uiContext: UIContext) => {
1298    myNode = new MyNodeController(false);
1299    myNode.init(uiContext);
1300  }
1301
1302export const getMyNode = (): MyNodeController | undefined => {
1303  return myNode;
1304}
1305```
1306
1307![zh-cn_image_NavigationNodeTransfer](figures/zh-cn_image_NavigationNodeTransfer.gif)
1308
1309### 结合BindSheet使用
1310
1311想实现半模态转场([bindSheet](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#bindsheet))的同时,组件从初始界面做一镜到底动画到半模态页面的效果,可以使用这样的设计思路。将[SheetOptions](../reference/apis-arkui/arkui-ts/ts-universal-attributes-sheet-transition.md#sheetoptions)中的mode设置为SheetMode.EMBEDDED,该模式下新起的页面可以覆盖在半模态弹窗上,页面返回后该半模态依旧存在,半模态面板内容不丢失。在半模态转场的同时设置一全模态转场([bindContentCover](../reference/apis-arkui/arkui-ts/ts-universal-attributes-modal-transition.md#bindcontentcover))页面无转场出现,该页面仅有需要做共享元素转场的组件,通过属性动画,展示组件从初始界面至半模态页面的一镜到底动效,并在动画结束时关闭页面,并将该组件迁移至半模态页面。
1312
1313以点击图片展开半模态页的场景为例,实现步骤为:
1314
1315- 在初始界面挂载半模态转场和全模态转场两个页面,半模态页按需布局,全模态页面仅放置一镜到底动效需要的组件,抓取布局信息,使其初始位置为初始界面图片的位置。点击初始界面图片时,同时触发半模态和全模态页面出现,因设置为SheetMode.EMBEDDED模式,此时全模态页面层级最高。
1316
1317- 设置不可见的占位图片置于半模态页上,作为一镜到底动效结束时图片的终止位置。利用[布局回调](../reference/apis-arkui/js-apis-arkui-inspector.md)监听该占位图片布局完成的时候,此时执行回调抓取占位图片的位置信息,随后全模态页面上的图片利用属性动画开始进行共享元素转场。
1318
1319- 全模态页面的动画结束时触发结束回调,关闭全模态页面,将共享元素图片的节点迁移至半模态页面,替换占位图片。
1320
1321- 需注意,半模态页面的弹起高度不同,其页面起始位置也有所不同,而全模态则是全屏显示,两者存在一高度差,做一镜到底动画时,需要计算差值并进行修正,具体可见demo。
1322
1323- 还可以配合一镜到底动画,给初始界面图片也增加一个从透明到出现的动画,使得动效更为流畅。
1324
1325```
1326├──entry/src/main/ets                 // 代码区
1327│  ├──entryability
1328│  │  └──EntryAbility.ets             // 程序入口类
1329│  ├──NodeContainer
1330│  │  └──CustomComponent.ets          // 自定义占位节点
1331│  ├──pages
1332│  │  └──Index.ets                    // 进行共享元素转场的主页面
1333│  └──utils
1334│     ├──ComponentAttrUtils.ets       // 组件位置获取
1335│     └──WindowUtils.ets              // 窗口信息
1336└──entry/src/main/resources           // 资源文件
1337```
1338
1339```ts
1340// index.ets
1341import { MyNodeController, createMyNode, getMyNode } from '../NodeContainer/CustomComponent';
1342import { ComponentAttrUtils, RectInfoInPx } from '../utils/ComponentAttrUtils';
1343import { WindowUtils } from '../utils/WindowUtils';
1344import { inspector } from '@kit.ArkUI'
1345
1346class AnimationInfo {
1347  scale: number = 0;
1348  translateX: number = 0;
1349  translateY: number = 0;
1350  clipWidth: Dimension = 0;
1351  clipHeight: Dimension = 0;
1352}
1353
1354@Entry
1355@Component
1356struct Index {
1357  @State isShowSheet: boolean = false;
1358  @State isShowImage: boolean = false;
1359  @State isShowOverlay: boolean = false;
1360  @State isAnimating: boolean = false;
1361  @State isEnabled: boolean = true;
1362
1363  @State scaleValue: number = 0;
1364  @State translateX: number = 0;
1365  @State translateY: number = 0;
1366  @State clipWidth: Dimension = 0;
1367  @State clipHeight: Dimension = 0;
1368  @State radius: number = 0;
1369  // 原图的透明度
1370  @State opacityDegree: number = 1;
1371
1372  // 抓取照片原位置信息
1373  private originInfo: AnimationInfo = new AnimationInfo;
1374  // 抓取照片在半模态页上位置信息
1375  private targetInfo: AnimationInfo = new AnimationInfo;
1376  // 半模态高度
1377  private bindSheetHeight: number = 450;
1378  // 半模态上图片圆角
1379  private sheetRadius: number = 20;
1380
1381  // 设置半模态上图片的布局监听
1382  listener:inspector.ComponentObserver = this.getUIContext().getUIInspector().createComponentObserver('target');
1383  aboutToAppear(): void {
1384    // 设置半模态上图片的布局完成回调
1385    let onLayoutComplete:()=>void=():void=>{
1386      // 目标图片布局完成时抓取布局信息
1387      this.targetInfo = this.calculateData('target');
1388      // 仅半模态正确布局且此时无动画时触发一镜到底动画
1389      if (this.targetInfo.scale != 0 && this.targetInfo.clipWidth != 0 && this.targetInfo.clipHeight != 0 && !this.isAnimating) {
1390        this.isAnimating = true;
1391        // 用于一镜到底的模态页的属性动画
1392        this.getUIContext()?.animateTo({
1393          duration: 1000,
1394          curve: Curve.Friction,
1395          onFinish: () => {
1396            // 模态转场页(overlay)上的自定义节点下树
1397            this.isShowOverlay = false;
1398            // 半模态上的自定义节点上树,由此完成节点迁移
1399            this.isShowImage = true;
1400          }
1401        }, () => {
1402          this.scaleValue = this.targetInfo.scale;
1403          this.translateX = this.targetInfo.translateX;
1404          this.clipWidth = this.targetInfo.clipWidth;
1405          this.clipHeight = this.targetInfo.clipHeight;
1406          // 修正因半模态高度和缩放导致的高度差
1407          this.translateY = this.targetInfo.translateY +
1408            (this.getUIContext().px2vp(WindowUtils.windowHeight_px) - this.bindSheetHeight
1409              - this.getUIContext().px2vp(WindowUtils.navigationIndicatorHeight_px) - this.getUIContext().px2vp(WindowUtils.topAvoidAreaHeight_px));
1410          // 修正因缩放导致的圆角差异
1411          this.radius = this.sheetRadius / this.scaleValue
1412        })
1413        // 原图从透明到出现的动画
1414        this.getUIContext()?.animateTo({
1415          duration: 2000,
1416          curve: Curve.Friction,
1417        }, () => {
1418          this.opacityDegree = 1;
1419        })
1420      }
1421    }
1422    // 打开布局监听
1423    this.listener.on('layout', onLayoutComplete)
1424  }
1425
1426  // 获取对应id的组件相对窗口左上角的属性
1427  calculateData(id: string): AnimationInfo {
1428    let itemInfo: RectInfoInPx =
1429      ComponentAttrUtils.getRectInfoById(WindowUtils.window.getUIContext(), id);
1430    // 首先计算图片的宽高与窗口宽高的比例
1431    let widthScaleRatio = itemInfo.width / WindowUtils.windowWidth_px;
1432    let heightScaleRatio = itemInfo.height / WindowUtils.windowHeight_px;
1433    let isUseWidthScale = widthScaleRatio > heightScaleRatio;
1434    let itemScale: number = isUseWidthScale ? widthScaleRatio : heightScaleRatio;
1435    let itemTranslateX: number = 0;
1436    let itemClipWidth: Dimension = 0;
1437    let itemClipHeight: Dimension = 0;
1438    let itemTranslateY: number = 0;
1439
1440    if (isUseWidthScale) {
1441      itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidth_px - itemInfo.width) / 2);
1442      itemClipWidth = '100%';
1443      itemClipHeight = this.getUIContext().px2vp((itemInfo.height) / itemScale);
1444      itemTranslateY = this.getUIContext().px2vp(itemInfo.top - ((this.getUIContext().vp2px(itemClipHeight) - this.getUIContext().vp2px(itemClipHeight) * itemScale) / 2));
1445    } else {
1446      itemTranslateY = this.getUIContext().px2vp(itemInfo.top - (WindowUtils.windowHeight_px - itemInfo.height) / 2);
1447      itemClipHeight = '100%';
1448      itemClipWidth = this.getUIContext().px2vp((itemInfo.width) / itemScale);
1449      itemTranslateX = this.getUIContext().px2vp(itemInfo.left - (WindowUtils.windowWidth_px / 2 - itemInfo.width / 2));
1450    }
1451
1452    return {
1453      scale: itemScale,
1454      translateX: itemTranslateX ,
1455      translateY: itemTranslateY,
1456      clipWidth: itemClipWidth,
1457      clipHeight: itemClipHeight,
1458    }
1459  }
1460
1461  // 照片页
1462  build() {
1463    Column() {
1464      Text('照片')
1465        .textAlign(TextAlign.Start)
1466        .width('100%')
1467        .fontSize(30)
1468        .padding(20)
1469      // 图片使用Resource资源,需用户自定义
1470      Image($r("app.media.flower"))
1471        .opacity(this.opacityDegree)
1472        .width('90%')
1473        .id('origin')// 挂载半模态页
1474        .enabled(this.isEnabled)
1475        .onClick(() => {
1476          // 获取原始图像的位置信息,将模态页上图片移动缩放至该位置
1477          this.originInfo = this.calculateData('origin');
1478          this.scaleValue = this.originInfo.scale;
1479          this.translateX = this.originInfo.translateX;
1480          this.translateY = this.originInfo.translateY;
1481          this.clipWidth = this.originInfo.clipWidth;
1482          this.clipHeight = this.originInfo.clipHeight;
1483          this.radius = 0;
1484          this.opacityDegree = 0;
1485          // 启动半模态页和模态页
1486          this.isShowSheet = true;
1487          this.isShowOverlay = true;
1488          // 设置原图为不可交互抗打断
1489          this.isEnabled = false;
1490        })
1491    }
1492    .width('100%')
1493    .height('100%')
1494    .padding({ top: 20 })
1495    .alignItems(HorizontalAlign.Center)
1496    .bindSheet(this.isShowSheet, this.mySheet(), {
1497      // Embedded模式使得其他页面可以高于半模态页
1498      mode: SheetMode.EMBEDDED,
1499      height: this.bindSheetHeight,
1500      onDisappear: () => {
1501        // 保证半模态消失时状态正确
1502        this.isShowImage = false;
1503        this.isShowSheet = false;
1504        // 设置一镜到底动画又进入可触发状态
1505        this.isAnimating = false;
1506        // 原图重新变为可交互状态
1507        this.isEnabled = true;
1508      }
1509    }) // 挂载模态页作为一镜到底动画的实现页
1510    .bindContentCover(this.isShowOverlay, this.overlayNode(), {
1511      // 模态页面设置为无转场
1512      transition: TransitionEffect.IDENTITY,
1513    })
1514  }
1515
1516  // 半模态页面
1517  @Builder
1518  mySheet() {
1519    Column({space: 20}) {
1520      Text('半模态页面')
1521        .fontSize(30)
1522      Row({space: 40}) {
1523        Column({space: 20}) {
1524          ForEach([1, 2, 3, 4], () => {
1525            Stack()
1526              .backgroundColor(Color.Pink)
1527              .borderRadius(20)
1528              .width(60)
1529              .height(60)
1530          })
1531        }
1532        Column() {
1533          if (this.isShowImage) {
1534            // 半模态页面的自定义图片节点
1535            ImageNode()
1536          }
1537          else {
1538            // 抓取布局和占位用,实际不显示
1539            // 图片使用Resource资源,需用户自定义
1540            Image($r("app.media.flower"))
1541              .visibility(Visibility.Hidden)
1542          }
1543        }
1544        .height(300)
1545        .width(200)
1546        .borderRadius(20)
1547        .clip(true)
1548        .id('target')
1549      }
1550      .alignItems(VerticalAlign.Top)
1551    }
1552    .alignItems(HorizontalAlign.Start)
1553    .height('100%')
1554    .width('100%')
1555    .margin(40)
1556  }
1557
1558  @Builder
1559  overlayNode() {
1560    // Stack需要设置alignContent为TopStart,否则在高度变化过程中,截图和内容都会随高度重新布局位置
1561    Stack({ alignContent: Alignment.TopStart }) {
1562      ImageNode()
1563    }
1564    .scale({ x: this.scaleValue, y: this.scaleValue, centerX: undefined, centerY: undefined})
1565    .translate({ x: this.translateX, y: this.translateY })
1566    .width(this.clipWidth)
1567    .height(this.clipHeight)
1568    .borderRadius(this.radius)
1569    .clip(true)
1570  }
1571}
1572
1573@Component
1574struct ImageNode {
1575  @State myNodeController: MyNodeController | undefined = new MyNodeController(false);
1576
1577  aboutToAppear(): void {
1578    // 获取自定义节点
1579    let node = getMyNode();
1580    if (node == undefined) {
1581      // 新建自定义节点
1582      createMyNode(this.getUIContext());
1583    }
1584    this.myNodeController = getMyNode();
1585  }
1586
1587  aboutToDisappear(): void {
1588    if (this.myNodeController != undefined) {
1589      // 节点下树
1590      this.myNodeController.onRemove();
1591    }
1592  }
1593  build() {
1594    NodeContainer(this.myNodeController)
1595  }
1596}
1597```
1598
1599```ts
1600// CustomComponent.ets
1601// 自定义占位节点,跨容器迁移能力
1602import { BuilderNode, FrameNode, NodeController } from '@kit.ArkUI';
1603
1604@Builder
1605function CardBuilder() {
1606  // 图片使用Resource资源,需用户自定义
1607  Image($r("app.media.flower"))
1608    // 避免第一次加载图片时图片闪烁
1609    .syncLoad(true)
1610}
1611
1612export class MyNodeController extends NodeController {
1613  private CardNode: BuilderNode<[]> | null = null;
1614  private wrapBuilder: WrappedBuilder<[]> = wrapBuilder(CardBuilder);
1615  private needCreate: boolean = false;
1616  private isRemove: boolean = false;
1617
1618  constructor(create: boolean) {
1619    super();
1620    this.needCreate = create;
1621  }
1622
1623  makeNode(uiContext: UIContext): FrameNode | null {
1624    if(this.isRemove == true){
1625      return null;
1626    }
1627    if (this.needCreate && this.CardNode == null) {
1628      this.CardNode = new BuilderNode(uiContext);
1629      this.CardNode.build(this.wrapBuilder)
1630    }
1631    if (this.CardNode == null) {
1632      return null;
1633    }
1634    return this.CardNode!.getFrameNode()!;
1635  }
1636
1637  getNode(): BuilderNode<[]> | null {
1638    return this.CardNode;
1639  }
1640
1641  setNode(node: BuilderNode<[]> | null) {
1642    this.CardNode = node;
1643    this.rebuild();
1644  }
1645
1646  onRemove() {
1647    this.isRemove = true;
1648    this.rebuild();
1649    this.isRemove = false;
1650  }
1651
1652  init(uiContext: UIContext) {
1653    this.CardNode = new BuilderNode(uiContext);
1654    this.CardNode.build(this.wrapBuilder)
1655  }
1656}
1657
1658let myNode: MyNodeController | undefined;
1659
1660export const createMyNode =
1661  (uiContext: UIContext) => {
1662    myNode = new MyNodeController(false);
1663    myNode.init(uiContext);
1664  }
1665
1666export const getMyNode = (): MyNodeController | undefined => {
1667  return myNode;
1668}
1669```
1670
1671```ts
1672// ComponentAttrUtils.ets
1673// 获取组件相对窗口的位置
1674import { componentUtils, UIContext } from '@kit.ArkUI';
1675import { JSON } from '@kit.ArkTS';
1676
1677export class ComponentAttrUtils {
1678  // 根据组件的id获取组件的位置信息
1679  public static getRectInfoById(context: UIContext, id: string): RectInfoInPx {
1680    if (!context || !id) {
1681      throw Error('object is empty');
1682    }
1683    let componentInfo: componentUtils.ComponentInfo = context.getComponentUtils().getRectangleById(id);
1684
1685    if (!componentInfo) {
1686      throw Error('object is empty');
1687    }
1688
1689    let rstRect: RectInfoInPx = new RectInfoInPx();
1690    const widthScaleGap = componentInfo.size.width * (1 - componentInfo.scale.x) / 2;
1691    const heightScaleGap = componentInfo.size.height * (1 - componentInfo.scale.y) / 2;
1692    rstRect.left = componentInfo.translate.x + componentInfo.windowOffset.x + widthScaleGap;
1693    rstRect.top = componentInfo.translate.y + componentInfo.windowOffset.y + heightScaleGap;
1694    rstRect.right =
1695      componentInfo.translate.x + componentInfo.windowOffset.x + componentInfo.size.width - widthScaleGap;
1696    rstRect.bottom =
1697      componentInfo.translate.y + componentInfo.windowOffset.y + componentInfo.size.height - heightScaleGap;
1698    rstRect.width = rstRect.right - rstRect.left;
1699    rstRect.height = rstRect.bottom - rstRect.top;
1700    return {
1701      left: rstRect.left,
1702      right: rstRect.right,
1703      top: rstRect.top,
1704      bottom: rstRect.bottom,
1705      width: rstRect.width,
1706      height: rstRect.height
1707    }
1708  }
1709}
1710
1711export class RectInfoInPx {
1712  left: number = 0;
1713  top: number = 0;
1714  right: number = 0;
1715  bottom: number = 0;
1716  width: number = 0;
1717  height: number = 0;
1718}
1719
1720export class RectJson {
1721  $rect: Array<number> = [];
1722}
1723```
1724
1725```ts
1726// WindowUtils.ets
1727// 窗口信息
1728import { window } from '@kit.ArkUI';
1729
1730export class WindowUtils {
1731  public static window: window.Window;
1732  public static windowWidth_px: number;
1733  public static windowHeight_px: number;
1734  public static topAvoidAreaHeight_px: number;
1735  public static navigationIndicatorHeight_px: number;
1736}
1737```
1738
1739```ts
1740// EntryAbility.ets
1741// 程序入口处的onWindowStageCreate增加对窗口宽高等的抓取
1742
1743import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
1744import { hilog } from '@kit.PerformanceAnalysisKit';
1745import { display, window } from '@kit.ArkUI';
1746import { WindowUtils } from '../utils/WindowUtils';
1747
1748const TAG: string = 'EntryAbility';
1749
1750export default class EntryAbility extends UIAbility {
1751  private currentBreakPoint: string = '';
1752
1753  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
1754    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
1755  }
1756
1757  onDestroy(): void {
1758    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onDestroy');
1759  }
1760
1761  onWindowStageCreate(windowStage: window.WindowStage): void {
1762    // Main window is created, set main page for this ability
1763    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
1764
1765    // 获取窗口宽高
1766    WindowUtils.window = windowStage.getMainWindowSync();
1767    WindowUtils.windowWidth_px = WindowUtils.window.getWindowProperties().windowRect.width;
1768    WindowUtils.windowHeight_px = WindowUtils.window.getWindowProperties().windowRect.height;
1769
1770    this.updateBreakpoint(WindowUtils.windowWidth_px);
1771
1772    // 获取上方避让区(状态栏等)高度
1773    let avoidArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
1774    WindowUtils.topAvoidAreaHeight_px = avoidArea.topRect.height;
1775
1776    // 获取导航条高度
1777    let navigationArea = WindowUtils.window.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
1778    WindowUtils.navigationIndicatorHeight_px = navigationArea.bottomRect.height;
1779
1780    hilog.info(0x0000, TAG, 'the width is ' + WindowUtils.windowWidth_px + '  ' + WindowUtils.windowHeight_px + '  ' +
1781    WindowUtils.topAvoidAreaHeight_px + '  ' + WindowUtils.navigationIndicatorHeight_px);
1782
1783    // 监听窗口尺寸、状态栏高度及导航条高度的变化并更新
1784    try {
1785      WindowUtils.window.on('windowSizeChange', (data) => {
1786        hilog.info(0x0000, TAG, 'on windowSizeChange, the width is ' + data.width + ', the height is ' + data.height);
1787        WindowUtils.windowWidth_px = data.width;
1788        WindowUtils.windowHeight_px = data.height;
1789        this.updateBreakpoint(data.width);
1790        AppStorage.setOrCreate('windowSizeChanged', Date.now())
1791      })
1792
1793      WindowUtils.window.on('avoidAreaChange', (data) => {
1794        if (data.type == window.AvoidAreaType.TYPE_SYSTEM) {
1795          let topRectHeight = data.area.topRect.height;
1796          hilog.info(0x0000, TAG, 'on avoidAreaChange, the top avoid area height is ' + topRectHeight);
1797          WindowUtils.topAvoidAreaHeight_px = topRectHeight;
1798        } else if (data.type == window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR) {
1799          let bottomRectHeight = data.area.bottomRect.height;
1800          hilog.info(0x0000, TAG, 'on avoidAreaChange, the navigation indicator height is ' + bottomRectHeight);
1801          WindowUtils.navigationIndicatorHeight_px = bottomRectHeight;
1802        }
1803      })
1804    } catch (exception) {
1805      hilog.error(0x0000, TAG, `register failed. code: ${exception.code}, message: ${exception.message}`);
1806    }
1807
1808    windowStage.loadContent('pages/Index', (err) => {
1809      if (err.code) {
1810        hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? '');
1811        return;
1812      }
1813      hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.');
1814    });
1815  }
1816
1817  updateBreakpoint(width: number) {
1818    let windowWidthVp = width / (display.getDefaultDisplaySync().densityDPI / 160);
1819    let newBreakPoint: string = '';
1820    if (windowWidthVp < 400) {
1821      newBreakPoint = 'xs';
1822    } else if (windowWidthVp < 600) {
1823      newBreakPoint = 'sm';
1824    } else if (windowWidthVp < 800) {
1825      newBreakPoint = 'md';
1826    } else {
1827      newBreakPoint = 'lg';
1828    }
1829    if (this.currentBreakPoint !== newBreakPoint) {
1830      this.currentBreakPoint = newBreakPoint;
1831      // 使用状态变量记录当前断点值
1832      AppStorage.setOrCreate('currentBreakpoint', this.currentBreakPoint);
1833    }
1834  }
1835
1836  onWindowStageDestroy(): void {
1837    // Main window is destroyed, release UI related resources
1838    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
1839  }
1840
1841  onForeground(): void {
1842    // Ability has brought to foreground
1843    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onForeground');
1844  }
1845
1846  onBackground(): void {
1847    // Ability has back to background
1848    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onBackground');
1849  }
1850}
1851```
1852
1853![zh-cn_image_BindSheetNodeTransfer](figures/zh-cn_image_BindSheetNodeTransfer.gif)
1854
1855## 使用geometryTransition共享元素转场
1856
1857[geometryTransition](../reference/apis-arkui/arkui-ts/ts-transition-animation-geometrytransition.md)用于组件内隐式共享元素转场,在视图状态切换过程中提供丝滑的上下文继承过渡体验。
1858
1859geometryTransition的使用方式为对需要添加一镜到底动效的两个组件使用geometryTransition接口绑定同一id,这样在其中一个组件消失同时另一个组件创建出现的时候,系统会对二者添加一镜到底动效。
1860
1861geometryTransition绑定两个对象的实现方式使得geometryTransition区别于其他方法,最适合用于两个不同对象之间完成一镜到底。
1862
1863### geometryTransition的简单使用
1864
1865对于同一个页面中的两个元素的一镜到底效果,geometryTransition接口的简单使用示例如下:
1866
1867```ts
1868import { curves } from '@kit.ArkUI';
1869
1870@Entry
1871@Component
1872struct IfElseGeometryTransition {
1873  @State isShow: boolean = false;
1874
1875  build() {
1876    Stack({ alignContent: Alignment.Center }) {
1877      if (this.isShow) {
1878        // 图片使用Resource资源,需用户自定义
1879        Image($r('app.media.spring'))
1880          .autoResize(false)
1881          .clip(true)
1882          .width(200)
1883          .height(200)
1884          .borderRadius(100)
1885          .geometryTransition("picture")
1886          .transition(TransitionEffect.OPACITY)
1887          // 在打断场景下,即动画过程中点击页面触发下一次转场,如果不加id,则会出现重影
1888          // 加了id之后,新建的spring图片会复用之前的spring图片节点,不会重新创建节点,也就不会有重影问题
1889          // 加id的规则为加在if和else下的第一个节点上,有多个并列节点则也需要进行添加
1890          .id('item1')
1891      } else {
1892        // geometryTransition此处绑定的是容器,那么容器内的子组件需设为相对布局跟随父容器变化,
1893        // 套多层容器为了说明相对布局约束传递
1894        Column() {
1895          Column() {
1896            // 图片使用Resource资源,需用户自定义
1897            Image($r('app.media.sky'))
1898              .size({ width: '100%', height: '100%' })
1899          }
1900          .size({ width: '100%', height: '100%' })
1901        }
1902        .width(100)
1903        .height(100)
1904        // geometryTransition会同步圆角,但仅限于geometryTransition绑定处,此处绑定的是容器
1905        // 则对容器本身有圆角同步而不会操作容器内部子组件的borderRadius
1906        .borderRadius(50)
1907        .clip(true)
1908        .geometryTransition("picture")
1909        // transition保证节点离场不被立即析构,设置通用转场效果
1910        .transition(TransitionEffect.OPACITY)
1911        .position({ x: 40, y: 40 })
1912        .id('item2')
1913      }
1914    }
1915    .onClick(() => {
1916      this.getUIContext()?.animateTo({
1917        curve: curves.springMotion()
1918      }, () => {
1919        this.isShow = !this.isShow;
1920      })
1921    })
1922    .size({ width: '100%', height: '100%' })
1923  }
1924}
1925```
1926
1927![zh-cn_image_0000001599644878](figures/zh-cn_image_0000001599644878.gif)
1928
1929### geometryTransition结合模态转场使用
1930
1931更多的场景中,需要对一个页面的元素与另一个页面的元素添加一镜到底动效。可以通过geometryTransition搭配模态转场接口实现。以点击头像弹出个人信息页的demo为例:
1932
1933```ts
1934class PostData {
1935  // 图片使用Resource资源,需用户自定义
1936  avatar: Resource = $r('app.media.flower');
1937  name: string = '';
1938  message: string = '';
1939  images: Resource[] = [];
1940}
1941
1942@Entry
1943@Component
1944struct Index {
1945  @State isPersonalPageShow: boolean = false;
1946  @State selectedIndex: number = 0;
1947  @State alphaValue: number = 1;
1948
1949  // 数组中图片均使用Resource资源,需用户自定义
1950  private allPostData: PostData[] = [
1951    { avatar: $r('app.media.flower'), name: 'Alice', message: '天气晴朗',
1952      images: [$r('app.media.spring'), $r('app.media.tree')] },
1953    { avatar: $r('app.media.sky'), name: 'Bob', message: '你好世界',
1954      images: [$r('app.media.island')] },
1955    { avatar: $r('app.media.tree'), name: 'Carl', message: '万物生长',
1956      images: [$r('app.media.flower'), $r('app.media.sky'), $r('app.media.spring')] }];
1957
1958  private onAvatarClicked(index: number): void {
1959    this.selectedIndex = index;
1960    this.getUIContext()?.animateTo({
1961      duration: 350,
1962      curve: Curve.Friction
1963    }, () => {
1964      this.isPersonalPageShow = !this.isPersonalPageShow;
1965      this.alphaValue = 0;
1966    });
1967  }
1968
1969  private onPersonalPageBack(index: number): void {
1970    this.getUIContext()?.animateTo({
1971      duration: 350,
1972      curve: Curve.Friction
1973    }, () => {
1974      this.isPersonalPageShow = !this.isPersonalPageShow;
1975      this.alphaValue = 1;
1976    });
1977  }
1978
1979  @Builder
1980  PersonalPageBuilder(index: number) {
1981    Column({ space: 20 }) {
1982      Image(this.allPostData[index].avatar)
1983        .size({ width: 200, height: 200 })
1984        .borderRadius(100)
1985        // 头像配置共享元素效果,与点击的头像的id匹配
1986        .geometryTransition(index.toString())
1987        .clip(true)
1988        .transition(TransitionEffect.opacity(0.99))
1989
1990      Text(this.allPostData[index].name)
1991        .font({ size: 30, weight: 600 })
1992        // 对文本添加出现转场效果
1993        .transition(TransitionEffect.asymmetric(
1994          TransitionEffect.OPACITY
1995            .combine(TransitionEffect.translate({ y: 100 })),
1996          TransitionEffect.OPACITY.animation({ duration: 0 })
1997        ))
1998
1999      Text('你好,我是' + this.allPostData[index].name)
2000        // 对文本添加出现转场效果
2001        .transition(TransitionEffect.asymmetric(
2002          TransitionEffect.OPACITY
2003            .combine(TransitionEffect.translate({ y: 100 })),
2004          TransitionEffect.OPACITY.animation({ duration: 0 })
2005        ))
2006    }
2007    .padding({ top: 20 })
2008    .size({ width: 360, height: 780 })
2009    .backgroundColor(Color.White)
2010    .onClick(() => {
2011      this.onPersonalPageBack(index);
2012    })
2013    .transition(TransitionEffect.asymmetric(
2014      TransitionEffect.opacity(0.99),
2015      TransitionEffect.OPACITY
2016    ))
2017  }
2018
2019  build() {
2020    Column({ space: 20 }) {
2021      ForEach(this.allPostData, (postData: PostData, index: number) => {
2022        Column() {
2023          Post({ data: postData, index: index, onAvatarClicked: (index: number) => { this.onAvatarClicked(index) } })
2024        }
2025        .width('100%')
2026      }, (postData: PostData, index: number) => index.toString())
2027    }
2028    .size({ width: '100%', height: '100%' })
2029    .backgroundColor('#40808080')
2030    .bindContentCover(this.isPersonalPageShow,
2031      this.PersonalPageBuilder(this.selectedIndex), { modalTransition: ModalTransition.NONE })
2032    .opacity(this.alphaValue)
2033  }
2034}
2035
2036@Component
2037export default struct  Post {
2038  @Prop data: PostData;
2039  @Prop index: number;
2040
2041  @State expandImageSize: number = 100;
2042  @State avatarSize: number = 50;
2043
2044  private onAvatarClicked: (index: number) => void = (index: number) => { };
2045
2046  build() {
2047    Column({ space: 20 }) {
2048      Row({ space: 10 }) {
2049        Image(this.data.avatar)
2050          .size({ width: this.avatarSize, height: this.avatarSize })
2051          .borderRadius(this.avatarSize / 2)
2052          .clip(true)
2053          .onClick(() => {
2054            this.onAvatarClicked(this.index);
2055          })
2056          // 对头像绑定共享元素转场的id
2057          .geometryTransition(this.index.toString(), {follow:true})
2058          .transition(TransitionEffect.OPACITY.animation({ duration: 350, curve: Curve.Friction }))
2059
2060        Text(this.data.name)
2061      }
2062      .justifyContent(FlexAlign.Start)
2063
2064      Text(this.data.message)
2065
2066      Row({ space: 15 }) {
2067        ForEach(this.data.images, (imageResource: Resource, index: number) => {
2068          Image(imageResource)
2069            .size({ width: 100, height: 100 })
2070        }, (imageResource: Resource, index: number) => index.toString())
2071      }
2072    }
2073    .backgroundColor(Color.White)
2074    .size({ width: '100%', height: 250 })
2075    .alignItems(HorizontalAlign.Start)
2076    .padding({ left: 10, top: 10 })
2077  }
2078}
2079```
2080
2081效果为点击主页的头像后,弹出模态页面显示个人信息,并且两个页面之间的头像做一镜到底动效:
2082
2083![zh-cn_image_0000001597320327](figures/zh-cn_image_0000001597320327.gif)
2084
2085<!--RP1--><!--RP1End-->