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