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