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