
大家好我是鸿蒙Jack本期以我的《时光旅记》APP为例聊一下我在项目里是怎么做二级页面转场动画的。这个场景很常见用户在旅行计划页点击“云端同步”页面不是生硬地换一屏而是先进入一个带导航栏的二级页面内容区域轻微上移、淡入、缩放复位。这个动作很短但它能让用户明确感知到“我从旅行计划进入了一个新的功能页面”而不是像弹窗一样临时盖了一层。我这次挑的就是《时光旅记》里的“旅行计划页 - 云端同步页”这个场景。为什么这个场景适合讲转场旅行计划不是一个孤立功能。它有计划列表、新建计划、攻略广场、云端同步、详情页、费用账本等多个入口。如果每个页面都自己写一套进入动画后面一定会变乱有的页面从下方进有的页面透明度慢半拍有的页面导航栏先出现、内容后出现。所以我在项目里没有把转场动画散落到每个业务页面而是抽了一个轻量组件SecondaryPageMotion。业务页面只负责决定“显示哪个页面”动画组件负责“页面内容怎么进入”。这套实现用到的技术栈主要是 HarmonyOS ArkTS、ArkUI 声明式 UI、ArkUI 属性动画、State状态驱动、组件生命周期、BuilderParam内容插槽以及kit.UIDesignKit里的HdsNavigation导航容器。项目里的调用关系下面是这个转场在《时光旅记》里的结构。falsetrueTravelPlanPage 旅行计划页isCloudSyncPageVisible旅行计划列表内容HdsNavigation 云端同步页面壳SecondaryPageMotionbuildCloudSyncPageContent 云同步业务内容点击云端同步入口openCloudSyncPage刷新本地旅行计划状态isCloudSyncPageVisible true这里有两个关键点。第一HdsNavigation负责页面壳包括标题栏、返回按钮、菜单按钮、系统安全区、导航栏材质等。转场动画不应该破坏这些页面结构。第二SecondaryPageMotion只包住页面内容。它不关心里面到底是云同步页、设置页、图片工厂页还是瞬间详情页。只要把内容通过content传进去就能复用同一套进入动画。用户点击后发生了什么从用户点击“云端同步”到页面完成进入大致是这条链路。云同步内容SecondaryPageMotionHdsNavigation状态变量TravelPlanPage用户云同步内容SecondaryPageMotionHdsNavigation状态变量TravelPlanPage用户点击云端同步openCloudSyncPage()collapseTabFloatingActionMenu()syncPlanListFromStore()isCloudSyncPageVisible true构建云端同步二级页挂载内容转场组件aboutToAppear() 设置 isVisible false40ms 后 isVisible true内容从透明、下移、轻微缩小过渡到正常状态这个 40ms 的延迟不是为了“拖慢页面”而是为了给 ArkUI 一次初始布局机会。组件先以初始态挂载再把isVisible改成true.animation()才能捕捉到状态变化并补上过渡过程。核心组件怎么写这是项目里的SecondaryPageMotion完整实现。import { curves } from kit.ArkUI; Component export struct SecondaryPageMotion { BuilderParam content: () void this.buildEmptyContent; State isVisible: boolean false; aboutToAppear(): void { this.isVisible false; setTimeout(() { this.isVisible true; }, 40); } aboutToDisappear(): void { this.isVisible false; } build(): void { Stack() { this.content(); } .width(100%) .height(100%) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .opacity(this.isVisible ? 1 : 0) .translate({ y: this.isVisible ? 0 : 24 }) .scale({ x: this.isVisible ? 1 : 0.985, y: this.isVisible ? 1 : 0.985 }) .animation({ duration: 420, curve: curves.springMotion() }) } Builder private buildEmptyContent(): void { } }这段代码里真正做动画的是这几行.opacity(this.isVisible ? 1 : 0) .translate({ y: this.isVisible ? 0 : 24 }) .scale({ x: this.isVisible ? 1 : 0.985, y: this.isVisible ? 1 : 0.985 }) .animation({ duration: 420, curve: curves.springMotion() })opacity负责淡入translate负责从下往上收回来scale负责从轻微缩小恢复到正常大小。动画曲线用的是curves.springMotion()它比普通线性动画更接近系统级页面进入的手感。这里用的是 ArkUI 的属性动画也就是组件属性绑定状态变量后当状态变化时由.animation()自动把差值补成连续动画。它和animateTo的区别是animateTo更适合一次性修改多个状态并统一指定动画参数.animation()更适合这种组件内部固定的属性变化。在旅行计划页里怎么调用在TravelPlanPage里我用isCloudSyncPageVisible控制当前展示旅行计划页还是展示云端同步页。下面这段是从项目代码里抽出来的核心调用方式。import { HdsNavigation, HdsNavigationTitleMode, SystemMaterialParams, hdsMaterial } from kit.UIDesignKit; import { SecondaryPageMotion } from ../../components/SecondaryPageMotion; Component export struct TravelPlanPage { Link isCloudSyncPageVisible: boolean; Prop useNavigationFrame: boolean true; State cloudSyncStatusMessage: string ; navigationMaterialEffect: SystemMaterialParams { materialType: hdsMaterial.MaterialType.ADAPTIVE, materialLevel: hdsMaterial.MaterialLevel.ADAPTIVE }; private openCloudSyncPage(): void { this.collapseTabFloatingActionMenu(); this.syncPlanListFromStore(); this.cloudSyncStatusMessage ; this.isCloudSyncPageVisible true; void this.refreshCloudSyncSheet(false); } build(): void { Stack({ alignContent: Alignment.Bottom }) { if (this.isCloudSyncPageVisible) { HdsNavigation() { SecondaryPageMotion({ content: (): void { this.buildCloudSyncPageContent(); } }); } .mode(NavigationMode.Stack) .titleBar({ content: { title: { mainTitle: 云端同步 }, backIcon: { label: $r(app.string.action_back), action: () { this.isCloudSyncPageVisible false; } }, menu: { maxCount: 1, value: [ { content: { label: 检查云端, icon: $r(sys.symbol.arrow_clockwise), action: () { void this.refreshCloudSyncSheet(true); } } } ] } }, style: { thermoCtrl: true, systemMaterialEffect: this.navigationMaterialEffect }, avoidLayoutSafeArea: true, enableComponentSafeArea: true }) .titleMode(HdsNavigationTitleMode.MINI) .hideBackButton(false) .ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.BOTTOM]) .systemBarStyle( { statusBarContentColor: Color.Black }, { statusBarContentColor: Color.Black } ) .width(100%) .height(100%) .backgroundColor(#F7F8FA); } else if (this.useNavigationFrame) { HdsNavigation() { SecondaryPageMotion({ content: (): void { this.buildContent(); } }); } .mode(NavigationMode.Stack) .titleBar({ content: { title: { mainTitle: 旅行计划 }, backIcon: { label: $r(app.string.action_back), action: () { this.onClose(); } }, menu: { maxCount: 3, value: [ { content: { label: 攻略广场, icon: $r(sys.symbol.map), action: () { this.openPlazaPage(); } } }, { content: { label: iCloud, icon: $r(sys.symbol.cloud_fill), action: () { this.openCloudSyncPage(); } } }, { content: { label: 新建计划, icon: $r(sys.symbol.plus), action: () { this.openCreatePlanComposer(); } } } ] } }, style: { thermoCtrl: true, systemMaterialEffect: this.navigationMaterialEffect }, avoidLayoutSafeArea: true, enableComponentSafeArea: true }) .titleMode(HdsNavigationTitleMode.MINI) .hideBackButton(false) .ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.BOTTOM]) .systemBarStyle( { statusBarContentColor: Color.Black }, { statusBarContentColor: Color.Black } ) .width(100%) .height(100%) .backgroundColor(#F7F8FA); } else { this.buildTabRootContent(); } } .width(100%) .height(100%); } private collapseTabFloatingActionMenu(): void { } private syncPlanListFromStore(): void { } private refreshCloudSyncSheet(_force: boolean): Promisevoid { return Promise.resolve(); } private openPlazaPage(): void { } private openCreatePlanComposer(): void { } private onClose(): void { } Builder private buildContent(): void { Column({ space: 16 }) { Button(云端同步) .onClick(() { this.openCloudSyncPage(); }) } .width(100%) .height(100%) .padding(24) .backgroundColor(#F7F8FA); } Builder private buildCloudSyncPageContent(): void { Column({ space: 16 }) { Text(云端同步) .fontSize(24) .fontWeight(FontWeight.Bold) Text(在这里查看旅行计划的同步状态、手动检查云端数据并处理本地未同步的计划。) .fontSize(15) .fontColor(#667085) } .width(100%) .height(100%) .padding(24) .backgroundColor(#F7F8FA); } Builder private buildTabRootContent(): void { this.buildContent(); } }上面这段代码保留了真实项目里的调用结构点击菜单后走openCloudSyncPage()再把isCloudSyncPageVisible置为true页面重新构建时进入云端同步分支。进入分支后HdsNavigation负责导航栏SecondaryPageMotion负责内容动效。实际项目里buildCloudSyncPageContent()会继续展示云同步额度、云端计划列表、同步状态、错误提示和“检查云端”操作。文章里我把业务内容压缩了方便看清转场能力本身。【此处放效果图云端同步页内容完成进入后的状态】如果只是某个小组件出现消失怎么办二级页面进入用SecondaryPageMotion就够了。但《时光旅记》里还有另一类场景页面不切换只是某个悬浮按钮、菜单项或者提示条出现消失。这时我不会再包一个页面级组件而是直接用TransitionEffect。比如旅行计划 Tab 的悬浮操作区里云同步按钮出现时用了透明度和平移组合.transition( TransitionEffect.OPACITY.animation({ duration: 180, curve: Curve.EaseOut }) .combine(TransitionEffect.translate({ y: 18 })) )它的含义很直接组件被插入时从透明、向下偏移 18vp 的状态进入组件被移除时反过来消失。TransitionEffect适合“组件有没有”的变化SecondaryPageMotion适合“整个二级页面内容进入”的变化。我在项目里大致按这个边界使用页面级进入用HdsNavigation SecondaryPageMotion。局部元素出现消失用TransitionEffect.OPACITY.combine(...)。状态属性连续变化比如悬浮按钮换边、列表整体淡入用.animation()或getUIContext()?.animateTo(...)。接入时最容易踩的点SecondaryPageMotion必须包在真实内容外层不要只包一个空容器。动画属性挂在Stack上里面的内容才会一起淡入、上移和缩放。如果你把isVisible true直接写在aboutToAppear()同步执行部分情况下初始态和结束态会在同一帧合并用户看不到动画。所以我这里用了setTimeout(..., 40)让初始态先完成挂载。另外.animation()的调用顺序要注意。ArkUI 的链式调用是从下往上作用的.animation()会作用到它上面已经声明的可动画属性。这里我把opacity、translate、scale都放在.animation()之前就是为了让它们共用同一组动画参数。小结这套转场能力本质上不复杂但它解决的是项目一致性问题。在《时光旅记》里我把二级页面进入统一成HdsNavigation SecondaryPageMotion导航容器管页面结构转场组件管内容进入业务页面只需要通过状态变量决定显示哪个页面。这样旅行计划、瞬间详情、设置页、图片工厂等页面都能保持同一种进入手感。如果你也在做 HarmonyOS 应用我建议先把页面级转场抽成一个很薄的组件不要一上来就给每个页面写一套动画。动画越分散后面越难统一组件越轻复用起来越自然。