HarmonyOS 应用开发进阶案例(十二):实现转场动画

45 阅读3分钟

本案例将介绍如何实现各种类型的转场动效。其中包括使用Navigation组件导航实现页面间转场,使用transition属性实现组件内转场,使用bindSheet实现模态转场,使用geometryTransition实现共享元素转场及卡片一镜到底动效。通过转场动效,可以平滑地过渡到下一个组件或页面,增加应用的连贯性和流畅性。

一、案例效果截图

二、案例运用到的知识点

  1. 核心知识点
  • 页面间转场(导航转场):页面的路由转场方式,对应一个界面消失,另外一个界面出现的动画效果。
  • 组件内转场 在组件插入和删除时显示过渡动效。
  • 模态转场:新的页面覆盖在旧的界面上,旧的页面不消失,新的页面出现的一种转场方式。
  • 共享元素转场 相同或者相似的两个元素做出的一种位置和大小匹配的过渡动画效果,也称一镜到底动效。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/@Param/AppStorageV2
  • 渲染控制:ForEach
  • 自定义组件和组件生命周期
  • 自定义构建函数@Builder
  • @Extend:定义扩展样式
  • Navigation导航组件与router路由导航
  • 内置组件:Swiper/Stack/Column/Row/Image/Grid/List
  • 常量与资源分类的访问
  • MVVM模式

三、代码结构

├──entry/src/main/ets                      // 代码区
│  ├──constants 
│  │  └── CommonConstants.ets              // 公共常量类 
│  ├──utils 
│  │  └── CustomNavigationUtil.ets         // 自定义转场动画工具类
│  ├──entryability
│  │  └──EntryAbility.ets                  // 程序入口类
│  ├──pages
│  │  ├──NavigationTransition.ets          // 页面间转场:默认导航转场页面
│  │  ├──ComponentTransition.ets           // 组件间转场页面
│  │  ├──CustomNavigationTransition.ets    // 页面间转场:自定义导航转场页面
│  │  ├──ModalTransition.ets               // 模态转场页面
│  │  ├──Index.ets                         // 应用首页
│  │  ├──longtaketransition
│  │  │  ├──LongTakeTransition.ets         // 卡片一镜到底列表页面
│  │  │  └──LongTakeDetail.ets             // 卡片一镜到底详情页面
│  │  └──geometrytransition 
│  │     ├──GeometryTransition.ets         // 共享元素转场页面
│  │     └──GeometryTransitionDetail.ets   // 共享元素转场详情页
│  └──views
│     └──TitleBar.ets                      // 自定义标题栏组件
└──entry/src/main/resources                // 资源文件目录

四、公共文件与资源

本案例涉及到的常量类代码如下:

  1. 通用常量类
// entry/src/main/ets/common/constants/CommonConstants.ets
export interface Route {
  title: ResourceStr
  to: string
}

export const ROUTES: Route[] = [
  {
    title: $r('app.string.title_navigation_transition'),
    to: 'NavigationTransition'
  },
  {
    title: $r('app.string.title_custom_transition'),
    to: 'CustomNavigationTransition'
  },
  {
    title: $r('app.string.title_component_transition'),
    to: 'ComponentTransition'
  },
  {
    title: $r('app.string.title_modal_transition'),
    to: 'ModalTransition'
  },
  {
    title: $r('app.string.title_share_item'),
    to: 'GeometryTransition'
  },
  {
    title: $r('app.string.title_long_take_transition'),
    to: 'LongTakeTransition'
  }
]

export const GEOMETRY_TRANSITION_ID: string = 'shareId'

本案例涉及到的资源文件如下:

  1. string.json
// entry/src/main/resources/base/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "模块描述"
    },
    {
      "name": "EntryAbility_desc",
      "value": "主程序入口"
    },
    {
      "name": "EntryAbility_label",
      "value": "转场动画的使用"
    },
    {
      "name": "Component_transition_toggle",
      "value": "Toggle"
    },
    {
      "name": "Component_transition_header",
      "value": "组件内转场"
    },
    {
      "name": "title_modal_transition",
      "value": "模态转场"
    },
    {
      "name": "Share_Item_header",
      "value": "共享元素转场"
    },
    {
      "name": "Share_Item_hint",
      "value": "点击查看共享元素转场动效"
    },
    {
      "name": "modal_transition_hint",
      "value": "点击查看模态转场动效"
    },
    {
      "name": "main_page_title",
      "value": "转场动画"
    },
    {
      "name": "title_navigation_transition",
      "value": "页面间转场: 默认"
    },
    {
      "name": "title_custom_transition",
      "value": "页面间转场: 自定义"
    },
    {
      "name": "title_component_transition",
      "value": "组件内转场"
    },
    {
      "name": "title_share_item",
      "value": "共享元素转场"
    },
    {
      "name": "title_long_take_transition",
      "value": "卡片一镜到底"
    },
    {
      "name": "content",
      "value": "出现和消失的两个节点有位置大小内容的关联"
    },
    {
      "name": "title_image",
      "value": "图片"
    }
  ]
}

其他资源请到源码中获取。

五、实现“导航转场:默认”效果

本节将使用Navigation组件导航实现页面间转场。Navigation组件导航提供了默认的页面切换时的转场动画,我们只需实现基础的页面间跳转,即会默认带有页面入场时从右侧滑入,退场时从右侧滑出的转场动效。

  1. 首页
// entry/src/main/ets/pages/Index.ets
import { ROUTES, Route } from '../constants/CommonConstants'
import { NavigationTransition } from './NavigationTransition'

@Entry
@ComponentV2
struct Index {
  @Provider() navPageInfos: NavPathStack = new NavPathStack()

  go(route: Route) {
    if (!route.to) {
      return
    }
    this.navPageInfos.pushPath({
      name: route.to,
      param: route.title
    })
  }

  @Builder
  navPageMap(name: string) {
    if (name === 'NavigationTransition') {
      NavigationTransition()
    }
  }

  build() {
    Navigation(this.navPageInfos) {
      Column() {
        Text($r('app.string.main_page_title'))
          .fontSize(30)
          .lineHeight(40)
          .fontWeight(700)
          .margin({
            top: 8,
            bottom: 8
          })
        Blank()
        ForEach(ROUTES, (route: Route) => {
          Button(route.title)
            .width('100%')
            .margin({ top: 12 })
            .onClick(() => {
              this.go(route)
            })
        }, (route: Route) => route.to)
      }
      .height('100%')
      .alignItems(HorizontalAlign.Start)
      .padding({
        top: 56,
        bottom: 16,
        left: 16,
        right: 16
      })
    }
    .hideToolBar(true)
    .mode(NavigationMode.Stack)
    .navDestination(this.navPageMap)
    .backgroundColor('#F1F3F5')
  }
}

关键代码说明:

  • 在build()中添加根组件Navigation,并设置导航相关属性。
  • 创建路由栈信息对象navPageInfos,为Button组件添加点击事件,使用navPageInfos.pushPath()方法跳转到相应的页面。
  1. 目标页
// entry/src/main/ets/pages/NavigationTransition.ets
@ComponentV2
export struct NavigationTransition {
  build() {
    NavDestination()
      .backgroundImage($r('app.media.bg_transition'))
      .backgroundImageSize(ImageSize.Cover)
  }
}

六、实现“导航转场:自定义-底部滑入滑出”效果

点击“页面间转场:自定义”按钮,跳转到自定义转场页面,页面入场时从底部滑入;点击返回按钮,页面退场时从底部滑出。

Navigation通过customNavContentTransition事件提供自定义转场动画的能力,通过以下三步可以实现一个自定义的转场动画。

  • 构建一个自定义转场动画工具类CustomNavigationUtils,通过一个Map管理各个页面自定义动画对象,页面在创建的时候将自己的自定义转场动画对象注册进去,销毁的时候解注册;
  • 实现一个转场协议对象NavigationAnimatedTransition,其中timeout属性表示转场结束的超时时间,默认为1000ms,transition属性为自定义的转场动画方法,开发者要在这里实现自己的转场动画逻辑,系统会在转场开始时调用该方法,onTransitionEnd为转场结束时的回调。
  • 在customNavContentTransition事件的回调方法中,返回实现的转场协议对象,如果返回undefined,则使用系统默认转场。
  1. 管理各个页面的自定义转场动画对象
// entry/src/main/ets/common/utils/CustomNavigationUtil.ets
// 定义动画回调接口
export interface AnimateCallback {
  timeout?: number // 动画超时时间(毫秒)
  animation?: (transitionProxy: NavigationTransitionProxy) => void // 动画执行回调
}

// 用于存储页面名称与对应动画配置的映射表
const customTransitionMap: Map<string, AnimateCallback> = new Map()

// 自定义导航动画控制类
export class CustomTransition {
  // 单例实例
  static delegate = new CustomTransition()

  // 当前导航操作类型(默认为 PUSH)
  operation: NavigationOperation = NavigationOperation.PUSH

  // 获取单例对象
  static getInstance() {
    return CustomTransition.delegate
  }

  /**
   * 注册导航参数,用于设置某个页面的动画参数
   * @param name 页面名称(唯一标识)
   * @param timeout 动画超时时间(可选)
   * @param animation 动画回调函数(可选)
   */
  registerNavParam(
    name: string,
    timeout: number,
    animation?: (transitionProxy: NavigationTransitionProxy) => void
  ): void {
    const params: AnimateCallback = {
      animation,
      timeout
    }
    customTransitionMap.set(name, params)
  }

  /**
   * 注销已注册的导航参数
   * @param name 页面名称(唯一标识)
   */
  unRegisterNavParam(name: string): void {
    customTransitionMap.delete(name)
  }

  /**
   * 获取指定页面的动画参数
   * @param name 页面名称(唯一标识)
   * @returns 动画参数对象,包括 timeout 和 animation 回调
   */
  getAnimateParam(name: string): AnimateCallback {
    const result: AnimateCallback = {
      timeout: customTransitionMap.get(name)?.timeout,
      animation: customTransitionMap.get(name)?.animation
    }
    return result
  }
}

2. 在首页中给Navigation组件添加customNavContentTransition事件

// entry/src/main/ets/pages/Index.ets
// ...
import { AnimateCallback, CustomTransition } from '../utils/CustomNavigationUtil'

@Entry
@ComponentV2
struct Index {
  // ...
  
  build() {
    Navigation(this.navPageInfos) {
      // ...
    }
    //...
    
    // 设置自定义的页面切换动画逻辑
    .customNavContentTransition((
      from: NavContentInfo, 
      to: NavContentInfo, operation: NavigationOperation
    ) => {
      // 获取起始页和目标页的动画配置
      const fromParam: AnimateCallback 
        = CustomTransition.getInstance().getAnimateParam(from.navDestinationId 
                                                         || '')
      const toParam: AnimateCallback 
        = CustomTransition.getInstance().getAnimateParam(to.navDestinationId 
                                                         || '')

      // 设置当前导航操作类型(PUSH / POP)
      CustomTransition.getInstance().operation = operation

      // 如果没有任何一个页面设置动画,则不做动画处理
      if (!fromParam.animation && !toParam.animation) {
        return undefined
      }

      // 返回自定义动画结构体,符合 NavigationAnimatedTransition 协议
      const customAnimation: NavigationAnimatedTransition = {
        timeout: 1000, // 动画超时时间(防止卡死)
        transition: (transitionProxy: NavigationTransitionProxy) => {
          // 分别执行起始页和目标页的动画回调(如果存在)
          fromParam.animation && fromParam.animation(transitionProxy)
          toParam.animation && toParam.animation(transitionProxy)
        }
      }
      return customAnimation
    })
  }
}

3. 创建自定义转场动画组件(目标页)

// entry/main/ets/pages/CustomNavigationTransition.ets
import { CustomTransition } from '../common/utils/CustomNavigationUtil'

@ComponentV2
export struct CustomNavigationTransition {
  // 用于控制页面平移动画的 Y 值,本地状态不触发 UI 重绘
  @Local translateY: string = '0'

  // 当前页面的导航目标 ID,用于注册和注销动画
  private navDestinationId: string = ''

  // 页面进入动画:从下往上滑入
  enterAnimation(transitionProxy: NavigationTransitionProxy) {
    this.translateY = '100%' // 初始设置页面在底部(不可见)
    animateToImmediately({
      duration: 0
    }, () => {
    })
    animateTo({
      duration: 600, // 动画时长 600ms
      onFinish: () => {
        transitionProxy.finishTransition() // 动画完成后通知导航系统结束过渡
      }
    }, () => {
      this.translateY = '0' // 平移到原位(可见区域)
    })
  }

  // 页面退出动画:从当前页面滑到底部隐藏
  exitAnimation(transitionProxy: NavigationTransitionProxy) {
    animateToImmediately({
      duration: 0
    }, () => {
    })
    animateTo({
      duration: 600, // 动画时长 600ms
      onFinish: () => {
        transitionProxy.finishTransition() // 动画结束通知导航完成
      }
    }, () => {
      this.translateY = '100%' // 平移到底部(不可见区域)
    })
  }

  build() {
    NavDestination()
      .backgroundImage($r('app.media.bg_transition'))
      .backgroundImageSize(ImageSize.Cover)
      .translate({ y: this.translateY }) // 绑定 translate 动画属性
      .onReady((context: NavDestinationContext) => {
        this.navDestinationId = context.navDestinationId! // 获取页面 ID

        // 注册动画回调(包括进入动画和退出动画)
        CustomTransition.getInstance().registerNavParam(
          this.navDestinationId, 2000,
          (transitionProxy: NavigationTransitionProxy) => {
            // 根据当前的导航操作类型决定执行哪种动画
            if (CustomTransition.getInstance().operation
              === NavigationOperation.PUSH) {
              this.enterAnimation(transitionProxy) // 进入动画
            } else if (CustomTransition.getInstance().operation
              === NavigationOperation.POP) {
              this.exitAnimation(transitionProxy) // 退出动画
            }
          })
      })
      .onDisAppear(() => {
        // 页面离开时注销该页面的动画回调,避免内存泄露
        CustomTransition.getInstance().unRegisterNavParam(this.navDestinationId)
      })
  }
}

关键代码说明:

  • animateToImmediately({ duration: 0 }, () => {}),额外添加这一句是因为animateTo暂不支持直接在状态管理V2中使用。解决原理为使用一个duration为0的animateToImmediately将额外的修改先刷新,再执行原来的动画达成预期的效果。(更多内容详见基础篇状态管理V2)
  1. 在首页里添加路由
// entry/src/main/ets/pages/Index.ets
// ...
import { CustomNavigationTransition } from './CustomNavigationTransition'

@Entry
@ComponentV2
struct Index {
  // ...

  @Builder
  navPageMap(name: string) {
    if (name === 'NavigationTransition') {
      // ...
    } else if (name === 'CustomNavigationTransition') {
      CustomNavigationTransition()
    }
  }

  // ...
}

七、组件内转场

组件内转场主要通过transition属性方法配置转场参数,在组件添加和移除时会执行过渡动效,需要配合animateTo才能生效。动效时长、曲线、延时跟随animateTo中的配置。通过按钮来控制组件的添加和移除,呈现容器子组件添加和移除时的动效。

  1. 组件内专场组件
// entry/src/main/ets/pages/ComponentTransition.ets
import { getResourceString } from '../common/utils/commonUtil'

@ComponentV2
export struct ComponentTransition {
  // 控制是否显示过渡动画元素
  @Local isShow: boolean = false

  // 元素出现时的过渡效果:从缩放 0 放大 + 渐变出现
  appearEffect 
    = TransitionEffect.scale({ x: 0, y: 0 }).combine(TransitionEffect.OPACITY)

  // 元素消失时的过渡效果:绕 Y 轴旋转 360 度 + 渐变消失
  disappearEffect = TransitionEffect.rotate({
    x: 0,
    y: 1,
    z: 0,
    angle: 360
  }).combine(TransitionEffect.OPACITY)

  build() {
    NavDestination() {
      Column() {
        // 条件渲染:isShow 为 true 时渲染带动画的图片
        if (this.isShow) {
          Image($r('app.media.bg_element')) // 元素图片资源
            .TransitionEleStyles() // 应用图片样式扩展
            .transition(TransitionEffect.asymmetric(
              this.appearEffect, 
              this.disappearEffect
            )) // 设置不对称过渡效果
        }

        // 永久显示的背景图,无动画
        Image($r('app.media.bg_element'))
          .TransitionEleStyles()

        Blank()

        // 按钮:切换 isShow 的状态,从而触发动画
        Button($r('app.string.Component_transition_toggle'))
          .width('100%')
          .onClick(() => {
            animateTo({ duration: 600 }, () => {
              this.isShow = !this.isShow // 切换显示状态,触发过渡动画
            })
          })
      }
      .padding(16)
      .height('100%')
      .width('100%')
    }
    .title(getResourceString(
      $r('app.string.title_component_transition'), 
      getContext(this)
    ))
  }
}

@Extend(Image)
function TransitionEleStyles() {
  .objectFit(ImageFit.Fill)
  .borderRadius(20)
  .margin({ bottom: 20 })
  .height(120)
  .width('100%')
}

关键代码说明:

  • 使用TransitionEffect提供的一些属性或方法(scale、rotate等)指定转场效果。
  • 通过TransitionEffect.combine()方法将不同的转场效果进行组合。
  • 该案例中图片组件出现和消失的动画非对称,使用TransitionEffect.asynmmetric()方法实现。
  • 在Button组件的onClick事件回调函数中调用animateTo方法,在animateTo方法的尾随闭包中改变isShow的值,使Image组件的添加移除动效生效。
  1. 在首页里添加路由
// entry/src/main/ets/pages/Index.ets
// ...
import { ComponentTransition } from './ComponentTransition'

@Entry
@ComponentV2
struct Index {
  // ...

  @Builder
  navPageMap(name: string) {
    if (name === 'NavigationTransition') {
      // ...
    } else if (name === 'CustomNavigationTransition') {
      // ...
    } else if (name === 'ComponentTransition') {
      ComponentTransition()
    }
  }

  // ...
}

八、模态转场

模态转场是新的界面覆盖在旧的界面上,旧的界面不消失的一种转场方式。本节将介绍使用bindSheet绑定半模态页面实现模态转场。

  1. 模态转场组件
// entry/src/main/ets/pages/ModalTransition.ets
import { getResourceString } from '../common/utils/commonUtil'

@Component
export struct ModalTransition {
  // 控制是否显示底部弹窗(Sheet)
  @State isShowSheet: boolean = false

  // 构建预览区域,展示一张图和一段文字
  @Builder
  previewArea() {
    Column() {
      // 可点击的图片,点击后弹出底部弹窗
      Image($r('app.media.bg_transition'))
        .width('100%')
        .height(147) 
        .borderRadius(8)
        .margin({ bottom: 12 })
        .onClick(() => {
          this.isShowSheet = true   // 设置状态为 true,触发显示弹窗
        })

      // 提示文本
      Text($r('app.string.modal_transition_hint'))
        .width('100%')
        .textAlign(TextAlign.Start)
        .fontSize(16) 
        .fontWeight(FontWeight.Medium)
        .fontColor($r('sys.color.font_primary'))
    }
    .borderRadius(12) 
    .backgroundColor(Color.White)
    .width('100%')
    .padding(12)
  }

  // 构建底部弹窗中的内容,这里是一张图片
  @Builder
  mySheet() {
    Image($r('app.media.bg_transition'))
  }

  build() {
    NavDestination() {
      Column() {
        // 构建页面主要内容区域
        this.previewArea()
      }
      .width('100%')
      .height('100%')
      .padding(16)
      // 绑定底部弹窗,当 isShowSheet 为 true 时显示 mySheet 内容
      .bindSheet($$this.isShowSheet, this.mySheet(), {
        height: SheetSize.MEDIUM // 弹窗高度为中等(系统预设)
      })
    }
    .backgroundColor('#F1F3F5')
    .title(getResourceString(
      $r('app.string.title_modal_transition'), 
      getContext(this)
    ))
  }
}

2. 在首页里添加路由

// entry/src/main/ets/pages/Index.ets
// ...
import { ModalTransition } from './ModalTransition'

@Entry
@ComponentV2
struct Index {
  // ...

  @Builder
  navPageMap(name: string) {
    if (name === 'NavigationTransition') {
      // ...
    } else if (name === 'CustomNavigationTransition') {
      // ...
    } else if (name === 'ComponentTransition') {
      // ...
    } else if (name === 'ModalTransition') {
      ModalTransition()
    }
  }

  // ...
}

九、共享元素转场

共享元素转场通过给组件设置geometryTransition属性来实现,相同或者相似的两个组件配置为同一个id,则转场过程中会执行共享元素转场。

  1. 转场前组件
// entry/src/main/ets/geometrytransition/GeometryTransition.ets
import { GEOMETRY_TRANSITION_ID } from '../../common/constants/CommonConstants'
import { getResourceString } from '../../common/utils/commonUtil'

@ComponentV2
export struct GeometryTransition {
  @Consumer() navPageInfos: NavPathStack = new NavPathStack()

  @Builder
  previewArea() {
    Column() {
      Image($r('app.media.bg_transition'))
        .width('100%')
        .height(147)
        .borderRadius(8)
        .margin({ bottom: 12 })
        .geometryTransition(GEOMETRY_TRANSITION_ID)
        .onClick(() => {
          animateTo({ duration: 600 }, () => {
            this.navPageInfos.pushPath({ 
              name: 'GeometryTransitionDetail' }, false)
          })
        })
      Text($r('app.string.Share_Item_hint'))
        .width('100%')
        .textAlign(TextAlign.Start)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor($r('sys.color.font_primary'))
    }
    .borderRadius(12)
    .backgroundColor(Color.White)
    .width('100%')
    .padding(12)
  }

  build() {
    NavDestination() {
      Column() {
        this.previewArea()
      }
      .width('100%')
      .height('100%')
      .padding(16)
    }
    .transition(TransitionEffect.OPACITY)
    .backgroundColor('#F1F3F5')
    .title(getResourceString(
      $r('app.string.title_share_item'), 
      getContext(this)
    ))
  }
}

关键代码说明:

  • 给转场前页面中的Image组件设置geometryTransition属性,组件转场id设置为“GEOMETRY_TRANSITION_ID”。
  • 在转场前页面的Image组件的点击事件中调用animateTo方法触发动画,并通过将pushPath方法的第二个参数设置为false关闭Navigation默认的转场。
  1. 转场后组件
// entry/src/main/ets/geometrytransition/GeometryTransitionDetail.ets
import { GEOMETRY_TRANSITION_ID } from '../../common/constants/CommonConstants'
import { getResourceString } from '../../common/utils/commonUtil'

@ComponentV2
export struct GeometryTransitionDetail {
  @Consumer() navPageInfos: NavPathStack = new NavPathStack()

  build() {
    NavDestination() {
      Image($r('app.media.bg_transition'))
        .width('100%')
        .height('60%')
        .margin({ top: 12 })
        .geometryTransition(GEOMETRY_TRANSITION_ID)
    }
    .transition(TransitionEffect.OPACITY)
    .backgroundColor('#F1F3F5')
    .title(getResourceString(
      $r('app.string.title_share_item'), 
      getContext(this)
    ))
    .onBackPressed(() => {
      animateTo({ duration: 600 }, () => {
        this.navPageInfos.pop(false)
      })
      return true
    })
  }
}

关键代码说明:

  • 给转场后页面中的Image组件设置geometryTransition属性,组件转场id设置为“GEOMETRY_TRANSITION_ID”。
  • 在转场后页面的NavDestination组件添加onBackPressed事件拦截页面返回操作,同样使用animateTo方法触发动画,并通过将navPageInfos.pop()方法的参数设置为false关闭Navigation默认的转场,在onBackPressed事件的回调中返回true阻止默认的页面返回行为。
  1. 在首页里添加路由
// entry/src/main/ets/pages/Index.ets
// ...
import { GeometryTransition } from './geometrytransition/GeometryTransition'
import { 
  GeometryTransitionDetail
} from './geometrytransition/GeometryTransitionDetail'

@Entry
@ComponentV2
struct Index {
  // ...

  @Builder
  navPageMap(name: string) {
    if (name === 'NavigationTransition') {
      // ...
    } else if (name === 'CustomNavigationTransition') {
      // ...
    } else if (name === 'ComponentTransition') {
      // ...
    } else if (name === 'ModalTransition') {
      // ...
    } else if (name === 'GeometryTransition') {
      GeometryTransition()
    } else if (name === 'GeometryTransitionDetail') {
      GeometryTransitionDetail()
    }
  }

  // ...
}

十、卡片一镜到底

一镜到底动效是页面切换时对相同或者相似的两个元素做的一种位置、大小等属性匹配的过渡动画效。

  1. 卡片组件
// entry/src/main/ets/pages/longtaketransition/LongTakeTransition.ets
import { util } from '@kit.ArkTS'
import { TitleBar } from '../../views/TitleBar'
import { LongTakeDetail } from './LongTakeDetail'

// 定义卡片的数据结构
export interface Card {
  id: string // 每张卡片的唯一标识
  image: Resource // 卡片展示的图片资源
  content: ResourceStr // 卡片下方文字内容资源
}

// 单张卡片组件
@ComponentV2
export struct CardView {
  @Param card: Card = new Object() as Card // 当前卡片数据
  @Param onCardClick: (card: Card) => void = () => {} // 卡片点击回调

  build() {
    Column() {
      // 显示图片
      Image(this.card.image)
        .width('100%')
        .objectFit(ImageFit.Auto)
        .draggable(false)

      // 显示文字内容
      Column() {
        Text(this.card.content)
          .fontColor('rgba(0, 0, 0, 0.6)')
          .fontSize(14)
      }
      .padding(12)
    }
    .backgroundColor(Color.White)
    .size({ width: '100%' })
    .onClick(() => {
      this.onCardClick(this.card)
    })
  }
}

// 模拟生成一组卡片数据(20 个)
const cardList = Array.from({ length: 20 }, (_: undefined, index) => {
  return {
    id: util.generateRandomUUID(), // 使用工具生成唯一 ID
    image: $r(`app.media.img_${(index % 6)}`), // 从资源中循环取 6 张图
    content: $r('app.string.content') // 统一的内容文字
  } as Card
})

// 主组件,控制卡片列表与详情页的切换
@ComponentV2
export struct LongTakeTransition {
  @Local showDetailView: boolean = false   // 是否显示详情页
  @Local selectedCard: Card | null = null  // 当前选中的卡片

  // 卡片点击事件:记录选中卡片并启动动画切换到详情页
  onCardClick(card: Card) {
    this.selectedCard = card
    animateTo({ duration: 600 }, () => {
      this.showDetailView = true
    })
  }

  // 返回列表页事件:通过动画切换回来
  onDetailViewBack() {
    animateTo({ duration: 600 }, () => {
      this.showDetailView = false
    })
  }

  build() {
    NavDestination() {
      Stack() {
        Column() {
          TitleBar({ title: $r('app.string.title_long_take_transition') })

          // 卡片瀑布流布局(两列)
          WaterFlow() {
            ForEach(cardList, (card: Card) => {
              FlowItem() {
                CardView({
                  card,
                  onCardClick: card => { this.onCardClick(card) }
                })
              }
              .width('100%')
              .borderRadius(10)
              .clip(true)
              .geometryTransition(card.id) // 加入几何变换动画的标记
            }, (card: Card) => card.id)
          }
          .columnsTemplate('1fr 1fr')
          .columnsGap(10)
          .rowsGap(10)
        }
        .width('100%')
        .height('100%')
        .padding({
          left: 16,
          right: 16
        })

        // 条件渲染详情页组件(带动画)
        if (this.showDetailView) {
          LongTakeDetail({
            card: this.selectedCard!, // 非空断言:此时必有值
            onBack: () => { this.onDetailViewBack() }
          })
        }
      }
    }
    .hideTitleBar(true)
    .backgroundColor('#F1F3F5')
    .onBackPressed(() => {
      if (!this.showDetailView) {
        return false  // 不拦截系统返回,交由系统处理
      }
      this.onDetailViewBack() // 拦截返回按钮,关闭详情页
      return true
    })
  }
}

关键代码说明:

  • 通过状态变量showDetailView控制卡片详情页的显隐,并给FlowItem组件添加geometryTransition属性,绑定geometryTransition属性的id对应每个卡片的id。
  • 给卡片绑定点击事件,同时使用animateTo()方法,在其回调函数中将showDetailView的值设置为true。在详情页中点回退时,在animateTo()方法的中将showDetailView的值设置为false。
  1. 导航标题组件
@ComponentV2
export struct TitleBar {
  @Param title: ResourceStr = ''
  @Consumer() navPageInfos: NavPathStack = new NavPathStack()
  @Param onBack: () => void = () => { this.navPageInfos.pop() }

  build() {
    Row({ space: 10 }) {
      Column() {
        SymbolGlyph($r('sys.symbol.chevron_left'))
          .fontSize(24)
          .fontWeight(FontWeight.Normal)
      }
      .width(40)
      .height(40)
      .margin({ right: 8 })
      .borderRadius('100%')
      .justifyContent(FlexAlign.Center)
      .backgroundColor($r('sys.color.comp_background_tertiary'))
      .onClick(() => {
        this.onBack()
      })
      Text(this.title)
        .width('100%')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
    }
    .width('100%')
    .height(56)
    .alignItems(VerticalAlign.Center)
  }
}

3. 卡片详情页

// entry/src/main/ets/pages/longtaketransition.ets
import { TitleBar } from '../../views/TitleBar'
import { Card } from './LongTakeTransition'

@ComponentV2
export struct LongTakeDetail {
  @Param card: Card = new Object() as Card
  @Param onBack: () => void = () => {}

  build() {
    Column() {
      TitleBar({
        title: $r('app.string.title_image'),
        onBack: () => this.onBack()
      })
        .padding({
          bottom: 12,
          left: 16,
          right: 16
        })
      Image(this.card.image)
        .width('100%')
        .objectFit(ImageFit.Auto)
    }
    .width('100%')
    .height('100%')
    .borderRadius(10)
    .backgroundColor('#F1F3F5')
    .geometryTransition(this.card.id)
    .transition(TransitionEffect.OPACITY)
    .expandSafeArea(
      [SafeAreaType.SYSTEM], 
      [SafeAreaEdge.TOP, 
       SafeAreaEdge.BOTTOM]
    )
  }
}

关键代码说明:

  • 用@Prop修饰card属性接收卡片数据,并给Column组件绑定geometryTransition属性的id为对应每个卡片的id。

✋ 需要参加鸿蒙认证的请点击 鸿蒙认证链接