20 EmptyState 空状态设计:插画、动画与交互反馈
20 EmptyState 空状态设计:插画、动画与交互反馈
前言
图:20 EmptyState 空状态设计:插画、动画与交互反馈 运行效果截图(HarmonyOS NEXT)
在 App 开发中,空状态(Empty State)是用户首次使用或数据尚未生成时的关键体验节点。一个优秀的空状态页不仅要告诉用户"这里还没有内容",更要引导用户采取行动来填充内容。
本文以"鹿鹿·笔迹心理分析"项目中的 [EmptyState.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/EmptyState.ets) 组件为例,全面解析鸿蒙空状态组件的设计思路——从插画布局到入场动画,从呼吸脉冲到定时器管理。
鸿蒙官方·aboutToAppear 生命周期:developer.huawei.com
项目源码仓库:harmony-app GitHub
图:EmptyState 三要素——插画区(入场动画)+ 文字区 + CTA 按钮(呼吸脉冲),aboutToAppear 触发两段动画
一、空状态组件的设计原则
1.1 三个核心要素
一个完整的空状态应包含以下三个要素:
| 要素 | 作用 | EmptyState 实现 |
|---|---|---|
| 插画/图标 | 视觉提示,唤起情感共鸣 | 手绘风纸笔插画(Stack 模拟) |
| 提示文字 | 说明当前状态 | title+subtitle双行文字 |
| 行动号召(CTA) | 引导用户操作 | 胶囊按钮 + 呼吸脉冲动画 |
@Componentexportstruct EmptyState{// 三要素@Proptitle:string='还没有分析记录,快来试试吧'// 提示文字@Propsubtitle:string='上传一段手写笔记 · 让 AI 读懂你'@PropctaText:string='拍下第一笔'// CTA 按钮文字onCtaClick?:()=>void// CTA 回调}1.2 UI 2.0 中的空状态规范
鸿蒙设计规范推荐的空状态原则:
- 友善而非冷漠:使用插画而非纯文字
- 引导而非指责:“还没有内容” > “内容为空”
- 单一 CTA:只提供一个主要操作入口
- 动态而非静止:加入细微动画提升浏览体验
提示:空状态中的插画应保持品牌一致性——色彩、风格、语气都需要与 App 整体调性统一。
二、EmptyState 的 UI 结构
2.1 整体布局
[EmptyState.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/EmptyState.ets) 采用 Column 从上到下依次排列插画区、文字区、按钮区:
build(){Column(){// ========== 插画区(手绘风纸笔) ==========Stack(){// 双层斜放纸张Column().width(140).height(110).backgroundColor(AppColors.PAPER).borderRadius(AppRadius.S).border({width:1,color:AppColors.LINE}).rotate({angle:-3})// 轻微旋转,模拟纸张随意摆放.shadow({radius:12,color:'rgba(61,53,46,.08)',offsetY:4})// 三道笔迹波浪线Column(){Text('~~~~').fontSize(20).fontColor(AppColors.PRIMARY).opacity(0.85)Text('~~~').fontSize(18).fontColor(AppColors.WARM).opacity(0.7).margin({top:8})Text('~~').fontSize(16).fontColor(AppColors.QUIET).opacity(0.55).margin({top:8})}.width(140).height(110).justifyContent(FlexAlign.Center).rotate({angle:2})// 右上角羽毛笔装饰Text('✒').fontSize(32).fontColor(AppColors.PRIMARY_DEEP).position({x:110,y:-10})}.width(180).height(140).opacity(this.illustrationOpacity)// 入场动画控制.translate({y:this.illustrationOffset})// ========== 文字区 ==========Text(this.title).fontSize(AppFonts.SUBTITLE).fontColor(AppColors.TEXT).margin({top:AppSpacing.XL}).letterSpacing(1)Text(this.subtitle).fontSize(AppFonts.CAPTION).fontColor(AppColors.TEXT_2).margin({top:AppSpacing.XS}).letterSpacing(1)// ========== CTA 按钮 ==========Button(this.ctaText).type(ButtonType.Capsule).backgroundColor(AppColors.PRIMARY).fontColor(Color.White).fontSize(AppFonts.BODY).padding({left:28,right:28,top:12,bottom:12}).margin({top:AppSpacing.XL}).scale({x:this.pulseScale,y:this.pulseScale})// 呼吸缩放.animation({duration:800,curve:Curve.EaseOut}).onClick(()=>this.onCtaClick?.())}.width('100%').height('100%').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center).backgroundColor(AppColors.BG)}2.2 手绘风插画的设计细节
插画通过 Stack 层叠 + 基础组件模拟手绘纸张效果:
// 纸张底部 — 双层斜放(-3° 旋转)Column().width(140).height(110).backgroundColor(AppColors.PAPER)// 米黄纸色.borderRadius(AppRadius.S)// 4vp 小圆角.border({width:1,color:AppColors.LINE})// 淡棕边框.rotate({angle:-3})// -3° 旋转(模拟随意摆放)// 笔迹波浪 — 三层递减透明度Text('~~~~').opacity(0.85)// 第一行:85%Text('~~~').opacity(0.7)// 第二行:70%Text('~~').opacity(0.55)// 第三行:55%设计细节:
rotate({ angle: -3 })— 纸张轻微旋转,打破"太整齐"的机械感- 三道波浪线长度递减(4/3/2 个波浪符),透明度递减
- 羽毛笔
position({ x: 110, y: -10 })超出 Stack 边界,增加趣味性
三、入场动画实现
3.1 aboutToAppear 生命周期
空状态的入场动画在aboutToAppear中触发——这是组件创建后、首次渲染前调用的生命周期方法:
// 入场动画状态@StateillustrationOpacity:number=0// 插画透明度(从 0 到 1)@StateillustrationOffset:number=20// 插画 Y 偏移(从 20 到 0)aboutToAppear():void{// 插画淡入 + 上移animateTo({duration:600,curve:Curve.FastOutSlowIn},()=>{this.illustrationOpacity=1// 透明度 0→1this.illustrationOffset=0// 偏移 20→0})// 启动呼吸脉冲定时器this.pulseTimer=setInterval(()=>{animateTo({duration:800,curve:Curve.EaseOut},()=>{this.pulseScale=this.pulseScale>1.05?1:1.06})},1600)}动画效果:
- 插画从下方 20vp 处淡入上移(600ms)
- CTA 按钮开始循环呼吸缩放(幅度 1.0↔1.06,周期 1600ms)
- 文字区无动画,自然渲染
提示:入场动画的 duration(600ms)略短于呼吸脉冲的周期(1600ms),确保入场完成后才开始呼吸,避免动画重叠。
四、呼吸脉冲动画
4.1 setInterval + animateTo 循环
脉冲动画使用setInterval定时器 +animateTo显式动画的递归模式:
privatepulseTimer:number=-1@StatepulseScale:number=1aboutToAppear():void{this.pulseTimer=setInterval(()=>{animateTo({duration:800,curve:Curve.EaseOut},()=>{this.pulseScale=this.pulseScale>1.05?1:1.06})},1600)// 每 1.6 秒触发一次脉冲}时序分析:
| 时间点 | 事件 | pulseScale | 按钮视觉 |
|---|---|---|---|
| T=0ms | 组件创建 | 1.0 | 原始大小 |
| T=1600ms | 第一次脉冲 | 1.0 → 1.06(800ms) | 缓慢放大 |
| T=2400ms | 第一次复位 | 1.06 → 1.0(800ms) | 缓慢恢复 |
| T=3200ms | 第二次脉冲 | 1.0 → 1.06(800ms) | 再放大 |
| … | 持续循环 | … | 持续呼吸 |
4.2 定时器清理
组件的生命结束时必须清理定时器,防止内存泄漏:
aboutToDisappear():void{// 清除脉冲定时器if(this.pulseTimer!==-1){clearInterval(this.pulseTimer)this.pulseTimer=-1}}关键实践:任何使用
setInterval/setTimeout的组件,都必须在aboutToDisappear中清理定时器。否则组件虽然销毁,定时器仍在运行,导致内存泄漏和潜在的逻辑错误。
五、CTA 按钮交互
5.1 回调模式
CTA 按钮不内嵌具体业务逻辑,而是通过回调暴露给父组件:
// 子组件:EmptyStateButton(this.ctaText).onClick(()=>this.onCtaClick?.())// 可选链调用// 父组件:HomePageEmptyState({ctaText:'写下第一笔',onCtaClick:()=>{this.getUIContext().getRouter().pushUrl({url:'pages/WritePage'})}})5.2 按钮样式参数
| 参数 | 值 | 设计考量 |
|---|---|---|
| type | ButtonType.Capsule | 胶囊形最友善、最现代 |
| backgroundColor | AppColors.PRIMARY | 品牌主色(暖棕),传递信任感 |
| fontColor | Color.White | 最大对比度,确保可读性 |
| fontSize | AppFonts.BODY(14) | 正文字号,不喧宾夺主 |
| padding | 左右 28 / 上下 12 | 充足留白,触控友好 |
| scale + animation | 呼吸脉冲 | 吸引力但不打扰 |
六、EmptyState 在不同页面的应用
6.1 首页空状态
// HomePage.ets — 用户首次进入时展示if(this.recentItems.length===0){EmptyState({title:'还没有分析记录,快来试试吧',ctaText:'拍下第一笔',onCtaClick:()=>{this.getUIContext().getRouter().pushUrl({url:'pages/CapturePage'})}})}6.2 档案页空状态
// ArchivePage.ets — 无档案时if(archives.length===0){EmptyState({title:'还没有创建任何档案',subtitle:'分析完成后自动生成档案',ctaText:'去分析',onCtaClick:()=>{/* 导航到分析页 */}})}提示:同一个 EmptyState 组件通过不同的
title/subtitle/ctaText组合,可以覆盖"无记录"、“无档案”、"无结果"等多个空状态场景。
七、空状态组件的可配置性
7.1 可配置参数一览
| 参数 | 类型 | 默认值 | 场景覆盖 |
|---|---|---|---|
title | string | “还没有分析记录” | 首页无数据 |
subtitle | string | “上传一段手写笔记” | 无资源 |
ctaText | string | “拍下第一笔” | 操作引导 |
onCtaClick | callback | undefined | 操作回调 |
7.2 扩展建议
空状态组件可以进一步扩展:
// 扩展版本(未来可考虑)@Componentexportstruct EmptyState{@Proptitle:string=''@Propsubtitle:string=''@PropctaText:string=''@PropillustrationType:IllustrationType=IllustrationType.PAPER// 插画类型@PropshowAnimation:boolean=true// 是否播放动画onCtaClick?:()=>void}八、EmptyState 的设计启示
8.1 空状态的层次感
一个好的空状态在视觉上应有明确的层次感:
- 视觉焦点:插画区(180×140vp,占据中心位置)
- 信息传达:文字区(title + subtitle,居中排列)
- 行动引导:CTA 按钮(Capsule 胶囊,带呼吸动画)
8.2 动画的克制
在空状态中使用动画需要克制:
- 插画入场动画只播放一次
- 按钮呼吸脉冲幅度控制在1.0↔1.06(不可过大)
- 脉冲周期1600ms(不急促,不拖延)
动画是为体验服务的——过度的动画会变成骚扰,让用户想快速离开页面。
8.3 空状态的性能考量
| 优化点 | 说明 | 影响 |
|---|---|---|
setInterval必须清理 | 防止内存泄漏和定时器堆积 | 直接影响应用稳定性 |
| 插画用基础组件模拟 | 无需加载图片资源,渲染更快 | 减少首次渲染耗时 |
动画使用animateTo | 框架统一调度,帧率更稳定 | 动画流畅度 |
@State最小化 | 只声明 3 个动画变量 | 减少不必要的重渲染 |
总结
本文从 [EmptyState.ets](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/EmptyState.ets) 的完整代码出发,深入解析了鸿蒙空状态组件的设计方法:
- 三要素结构:插画 + 文字 + CTA 按钮,覆盖空状态的核心需求
- 入场动画:
aboutToAppear中触发淡入上移动画,600ms 自然过渡 - 呼吸脉冲:
setInterval+animateTo实现循环缩放,1700ms 周期 - 定时器管理:
aboutToDisappear中清理定时器,防止内存泄漏 - 回调模式:CTA 操作通过
onCtaClick回调交由父组件处理 - 可配置性:通过多个 @Prop 参数适配不同场景的空状态
下一篇文章将介绍PosterTemplate 分享海报模板,解析主题系统、排版布局和二维码占位设计。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
参考资源:
- aboutToAppear 生命周期
- setInterval 定时器管理
- animateTo 显式动画
- Button 组件参考
- [EmptyState 项目源码](file:///Users/fiona/Downloads/bijixinli/harmony-app/entry/src/main/ets/common/components/EmptyState.ets)
- ArkUI 组件生命周期
- 鸿蒙设计规范:空状态
- HarmonyOS 官方示例
- ArkUI 动画开发概述
- animateTo 显式动画详解
- 鸿蒙开发者社区
- HarmonyOS NEXT 应用开发指南