1# 共享元素转场 (一镜到底) 2 3共享元素转场是一种界面切换时对相同或者相似的两个元素做的一种位置和大小匹配的过渡动画效果,也称一镜到底动效。 4 5如下例所示,在点击图片后,该图片消失,同时在另一个位置出现新的图片,二者之间内容相同,可以对它们添加一镜到底动效。左图为不添加一镜到底动效的效果,右图为添加一镜到底动效的效果,一镜到底的效果能够让二者的出现消失产生联动,使得内容切换过程显得灵动自然而不生硬。 6 7| 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 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 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 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 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 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 2055 2056<!--RP1--><!--RP1End-->