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