鸿蒙实战:FormCardsPreview 桌面卡片预览与 EmptyPage 空状态设计
前言
图:鸿蒙实战第98篇:FormCardsPreview 桌面卡片预览与 EmptyPage 空状态设计 运行效果截图(HarmonyOS NEXT)
本篇覆盖两个在业务链路外但对用户体验非常重要的页面:FormCardsPreview(桌面 Widget 三种尺寸预览)和EmptyPage(空状态页面)。这两个页面都大量使用 Canvas 绘制手绘风格插图,且遵循完全不同的设计目标——前者展示功能,后者营造情绪。
重点拆解:
- Canvas 六层绘制系统(EmptyPage 的叠纸 + 羽毛笔插画)
- 三种卡片尺寸规范(2×2 / 2×4 / 4×4 HarmonyOS Widget)
drawMiniTrend迷你折线图(110×65 Canvas + 渐变填充)- 三卡片交错入场动画
系列导航:97 UnclassifiedArchivePage · 99 设计模式回顾
相关文档:Canvas API · FormExtensionAbility · Button API
一、EmptyPage Canvas 六层插画
1.1 绘制层次全景
1.2 纸张倾斜 save/restore 模式
每一层纸张的旋转都用save/translate/rotate/translate/restore五步标准模式:
// 后面一张纸(-8°旋转)ctx.save()ctx.translate(90,80)// ① 移到旋转中心ctx.rotate(-8*Math.PI/180)// ② 旋转 -8°ctx.translate(-90,-80)// ③ 移回坐标原点ctx.beginPath()ctx.roundRect(36,34,100,100,8)ctx.fillStyle='#FFFCF7'ctx.fill()ctx.strokeStyle='#D9CDB7'ctx.lineWidth=1.5ctx.stroke()// 虚线信纸横线ctx.setLineDash([3,4])// ← 虚线:3px线段 + 4px间隙ctx.strokeStyle='#EAE3D7'ctx.beginPath()for(letyof[58,74,90]){ctx.moveTo(50,y)ctx.lineTo(112,y)ctx.stroke()}ctx.restore()// ④ 恢复变换矩阵(必须)1.3 羽毛笔三子图形
羽毛笔由三个子图形组成:
ctx.save()ctx.translate(130,110)ctx.rotate(35*Math.PI/180)// 35° 斜放// 子图形①:笔杆(圆角矩形)ctx.beginPath()ctx.roundRect(-3,-28,6,56,3)ctx.fillStyle='#A8907A'ctx.fill()// 子图形②:笔尖(等腰三角形)ctx.beginPath()ctx.moveTo(-3,28);ctx.lineTo(3,28);ctx.lineTo(0,38)ctx.closePath()ctx.fillStyle='#3D352E'// 深棕:墨水色ctx.fill()// 子图形③:羽毛(双段二次贝塞尔轮廓)ctx.beginPath()ctx.moveTo(3,-26)ctx.quadraticCurveTo(20,-32,27,-46)// 外弧ctx.quadraticCurveTo(21,-18,3,-14)// 内弧ctx.closePath()ctx.fillStyle='rgba(217,166,137,0.85)'ctx.fill()ctx.restore()| 子图形 | API | 颜色 | 特点 |
|---|---|---|---|
| 笔杆 | roundRect | #A8907A暖棕 | 圆角 3px,整体感 |
| 笔尖 | 三角形路径 | #3D352E深棕 | 暗示墨水/笔尖 |
| 羽毛 | 双段贝塞尔 | 暖棕 85% 透明 | 略透明,轻盈感 |
二、三种 Widget 卡片规范
2.1 HarmonyOS Widget 尺寸标准
| 尺寸 | 网格 | 像素(参考) | 信息密度 | 场景 |
|---|---|---|---|---|
| 2×2 | 2 列 × 2 行 | 约 180×180 | 最低 | 单一核心数据 |
| 2×4 | 2 列 × 4 行 | 约 180×380 | 中 | 趋势 + 数值 |
| 4×4 | 4 列 × 4 行 | 约 380×380 | 高 | 完整雷达 + 详情 |
2.2 2×2 小卡:今日情绪
// 2×2 卡片 1:今日情绪能量Stack(){// 背景装饰圆(渐变,右上角)Column().width(60).height(60).borderRadius(30).linearGradient({angle:0,colors:[['rgba(217,166,137,0.3)',0],['transparent',0.7]]}).position({x:'60%',y:0})// 内容Column({space:4}){Text('今日情绪').fontSize(11).fontColor(AppColors.TEXT_2)Text('62').fontSize(36).fontFamily(AppFonts.SERIF).fontWeight(AppFonts.W_MEDIUM).fontColor(AppColors.TEXT)Text('💛 温和偏暖').fontSize(10).fontColor(AppColors.WARM)Button('📷 拍一张').type(ButtonType.Capsule).fontSize(10).height(24).onClick(()=>router.pushUrl({url:'pages/CapturePage'}))}.alignItems(HorizontalAlign.Start).padding(14)}2.3 2×4 中卡:7 天趋势
Row(){// 左侧:数字区Column({space:6}){Text('近 7 天情绪').fontSize(11).fontColor(AppColors.TEXT_2)Text('在变得舒展').fontSize(13).fontColor(AppColors.TEXT)Text('62/100').fontSize(12).fontColor(AppColors.TEXT_2)// 进度条Column().height(4).width('62%').backgroundColor(AppColors.PRIMARY).borderRadius(2)}.layoutWeight(1).alignItems(HorizontalAlign.Start)// 右侧:迷你折线图Column(){Text('趋势 ↗').fontSize(10).fontColor(AppColors.QUIET)Canvas(this.trendCtx).width(110).height(65).onReady(()=>this.drawMiniTrend())}}2.4 drawMiniTrend:迷你趋势图(110×65)
privatedrawMiniTrend():void{constctx=this.trendCtxconstw=110,h=65constdata=this.mockTrendData// 7 个数据点// ① 渐变填充区域constgradient=ctx.createLinearGradient(0,0,0,h)gradient.addColorStop(0,'rgba(168,144,122,0.3)')gradient.addColorStop(1,'rgba(168,144,122,0)')ctx.fillStyle=gradient ctx.beginPath()ctx.moveTo(0,h)data.forEach((val,i)=>{constx=(i/(data.length-1))*wconsty=h-(val/100)*(h-10)-5if(i===0)ctx.lineTo(x,y)elsectx.lineTo(x,y)})ctx.lineTo(w,h)ctx.closePath()ctx.fill()// ② 折线ctx.strokeStyle='#A8907A'ctx.lineWidth=1.5ctx.beginPath()data.forEach((val,i)=>{constx=(i/(data.length-1))*wconsty=h-(val/100)*(h-10)-5if(i===0)ctx.moveTo(x,y)elsectx.lineTo(x,y)})ctx.stroke()// ③ 最后一个端点高亮constlastX=wconstlastY=h-(data[data.length-1]/100)*(h-10)-5ctx.fillStyle='#A8907A'ctx.beginPath()ctx.arc(lastX,lastY,3,0,Math.PI*2)ctx.fill()}三、三卡片交错入场动画
privatestartEntranceAnimation():void{// 2×2 两张卡片:每 150ms 交错for(leti=0;i<2;i++){setTimeout(()=>{animateTo({duration:500,curve:Curve.FastOutSlowIn},()=>{this.card2x2Opacity[i]=1this.card2x2Offset[i]=0})},i*150)}// 2×4 中卡:400ms 后入场setTimeout(()=>{animateTo({duration:500,curve:Curve.FastOutSlowIn},()=>{this.card2x4Opacity=1this.card2x4Offset=0})},400)// 4×4 大卡:600ms 后入场setTimeout(()=>{animateTo({duration:600,curve:Curve.FastOutSlowIn},()=>{this.card4x4Opacity=1this.card4x4Offset=0})},600)}| 卡片 | 入场延迟 | 特点 |
|---|---|---|
| 2×2(第一张) | 0ms | 最先出现,吸引注意 |
| 2×2(第二张) | 150ms | 错开 150ms |
| 2×4 | 400ms | 中等延迟,叙事节奏 |
| 4×4 | 600ms | 压轴登场,大尺寸 |
四、FormCardsPreview vs 真实 Widget
重要说明:
FormCardsPreview是一个纯预览页,用于开发阶段预览卡片设计。真实的桌面 Widget(负一屏)需要通过以下额外配置实现:
FormExtensionAbility:实现onUpdateForm生命周期,提供卡片数据form_config.json:声明卡片名称、尺寸、刷新间隔module.json5中注册extensionAbilities- 卡片
.ets文件:使用@Entry @Form struct标记(而非@Entry @Component)
详见官方文档:HarmonyOS Widget 开发指南
九、注意事项与常见问题
9.1 开发注意事项
在实际开发过程中,需特别注意以下几点:
- API 兼容性:部分接口仅在特定 HarmonyOS NEXT 版本中可用,需做版本条件判断
- 权限模型:采用静态声明(module.json5)+ 动态申请(requestPermissionsFromUser)的两阶段授权
- 生命周期:合理使用
aboutToAppear()和aboutToDisappear()管理资源初始化与释放 - 状态同步:跨页面数据通过 AppStorage 共享,组件内状态使用
@State/@Prop/@Link装饰器
9.2 常见错误与解决方案
常见问题快速排查表:
| 问题类型 | 排查方向 | 参考方法 |
|---|---|---|
| 应用崩溃 | 查看 hilog 错误日志 | hilog.error(TAG, "...", e.message) |
| 状态丢失 | 检查 AppStorage 键名拼写 | 统一使用常量管理键名 |
| 动画不流畅 | 避免在 animateTo 回调中执行 I/O | 动画与数据操作分离 |
十、最佳实践与性能优化
10.1 代码组织规范
以下规范可显著提升代码可读性与可维护性:
- 分层解耦:UI 逻辑(pages/)与数据操作(DAO/Service)严格分离,不在 build() 中调用数据库
- 组件拆分:单个
@Component保持 100~200 行以内,大型组件拆分为@Builder子函数 - 令牌约束:颜色、字号、间距统一引用
AppColors/AppFonts/AppAnimations,禁止魔数 - 错误处理:异步方法统一返回
Promise<T | null>,异常时hilog.error记录并返回null
10.2 性能优化要点
以下是常见性能优化措施的对比分析:
| 优化维度 | 优化前 | 优化后 | 效果 |
|---|---|---|---|
| 列表渲染 | ForEach全量渲染 | LazyForEach按需渲染 | 内存降低 40-60% |
| 图片加载 | 不指定尺寸 | 指定width/height | 减少布局重计算 |
| 数据库查询 | 每次重新查询 | 合理缓存 + 精确 Predicates | 查询速度提升 5-10x |
| 动画实现 | 逐帧手动绘制 | animateTo属性动画 | 稳定 60fps |
| 状态更新 | 全局刷新 | @State精细化作用域 | 减少无效渲染 |
总结
本篇涵盖了两个视觉设计重点页面的技术实现:
- 六层 Canvas 插画:save/rotate/restore 矩阵变换,零图片资源实现叠纸+羽毛笔插画
- 三种 Widget 尺寸:2×2/2×4/4×4 信息密度递增,符合 HarmonyOS Widget 设计规范
drawMiniTrend迷你图:110×65 Canvas 中渐变填充 + 折线 + 端点,空间极致利用- 压轴交错入场:四张卡片 0/150/400/600ms 错开,形成"依次登场"的视觉叙事
FormCardsPreview定位:开发调试用预览页,真实 Widget 需配合FormExtensionAbility
下一篇预告:第99篇 页面汇总与 7 大共享设计模式回顾
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- Canvas CanvasRenderingContext2D
- HarmonyOS Widget 桌面卡片开发指南
- FormExtensionAbility API
- animateTo 显式动画
- 第44篇 Canvas 组件基础
- 第50篇 Canvas 动画与绘制进度驱动
- 第89篇 LoginPage Canvas 插画
- 第59篇 入场动画分段式设计
七、桌面卡片与空状态的设计精要
7.1 Widget 三种尺寸的差异化设计
2×2(小): 只显示最新情绪分数 + 今日推荐语 2×4(中): 情绪分数 + 最近3条记录列表 4×4(大): 完整的情绪雷达图 + 今日状态 + 快捷入口三个尺寸遵循渐进信息展示原则:小→大,信息量递增,核心数据始终可见。
7.2 FormExtensionAbility 与主应用数据共享
Widget 数据来自数据库,但 FormExtensionAbility 运行在独立进程:
// 方案:通过 SharedPreferences 共享轻量数据// 主 App 写入constprefs=awaitpreferences.getPreferences(context,'widget_data');awaitprefs.put('latest_score',latestScore);awaitprefs.put('personality_type',personalityType);awaitprefs.flush();// FormExtensionAbility 读取constprefs=awaitpreferences.getPreferences(formContext,'widget_data');constscore=prefs.getSync('latest_score',0)asnumber;7.3 EmptyPage 的情绪化设计
EmptyPage 不只是「没有数据」的提示,而是一个温暖的引导页:
| 场景 | 图片 | 文案风格 |
|---|---|---|
| 首次使用 | Canvas 绘制的小鹿插画 | 「从这里开始,记录你的笔迹故事」 |
| 搜索无结果 | 放大镜+星星 | 「没找到,换个关键词试试?」 |
| 档案为空 | 空信封插画 | 「还没有记录,拍一张开始吧~」 |
| 网络断开 | 失联的WiFi信号 | 「好像断网了,检查一下网络吧」 |
每种场景的配图风格统一(手绘线条 + 米色调),确保空状态页面「在其位,不失格」。
八、FormCards 与 EmptyPage 技术进阶
8.1 桌面卡片数据刷新机制
FormExtensionAbility 通过FormProvider.updateForm主动推送数据:
import{formProvider,formBindingData}from'@kit.FormKit';// 在 UIAbility 中触发卡片更新asyncrefreshWidget(formId:string):Promise<void>{constreport=awaitReportDao.getLatest();constdata=formBindingData.createFormBindingData({dominantTrait:report.dominantTrait,matchScore:report.matchScore,emotionLabel:report.emotionTags[0]??'平静'});awaitformProvider.updateForm(formId,data);}8.2 三种卡片尺寸适配
| 卡片尺寸 | 格子数 | 展示内容 |
|---|---|---|
| 1×2(小) | 2 | 主人格图标 + 得分 |
| 2×2(中) | 4 | 人格 + 情绪标签 + 日期 |
| 2×4(大) | 8 | 完整摘要 + 匹配度圆环 |
// 根据卡片尺寸动态渲染@BuilderrenderByDimension(dimension:formInfo.FormDimension){if(dimension===formInfo.FormDimension.Dimension_1_2){this.SmallCard()}elseif(dimension===formInfo.FormDimension.Dimension_2_2){this.MediumCard()}else{this.LargeCard()}}8.3 EmptyState 动画增强
空状态插画配合入场动画效果更佳:
@StateillustrationScale:number=0.8;@StateillustrationOpacity:number=0;aboutToAppear(){animateTo({duration:600,curve:Curve.EaseOut},()=>{this.illustrationScale=1.0;this.illustrationOpacity=1;});}如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!