鸿蒙ArkUI声明式范式深度实战:金融记账场景下银行级视觉语言、金币特效动画与社区化攒钱目标管理的全栈构建方案
一、技术前言
在移动互联网高速发展的今天,年轻人的理财意识正在经历一场前所未有的觉醒。从"月光族"到"攒钱打卡",从"冲动消费"到"目标储蓄",从"闭门造车"到"社区互助"——这背后不仅是消费观念的转变,更是工具驱动的行为重塑。一款优秀的攒钱应用,不能只是冰冷的数字账本,它需要兼顾社区陪伴感、游戏化激励和银行级的专业信任感三重定位。本文将深入剖析一款基于 HarmonyOS ArkUI 框架构建的"掌上攒"应用,它将年轻人攒钱目标管理与记账社区融为一体,在视觉上采用银行风格头部搭配金币下落特效,在功能上覆盖愿望清单、账单分析、攒钱挑战和理财笔记四大核心场景。
1.1 行业背景:年轻人攒钱赛道的崛起

近年来,国内年轻人群体中"攒钱"已从一种被动行为演变为主动生活方式。支付宝余额宝、微信零钱通培养了零钱理财习惯,各种"365天存钱法""52周攒钱法"在社交媒体广泛传播,闲鱼二手交易回血成为副业标配。但现有工具要么过于银行化(功能强但缺乏社交激励),要么过于社交化(有社区氛围但缺乏专业记账)。"掌上攒"应用正是瞄准了这个空白:以银行级视觉建立专业信任,以社区打卡和挑战机制注入社交激励,以愿望清单和进度条提供目标可视化驱动。这种"专业+社交+目标"三位一体的产品定位,在技术实现上要求视觉风格既沉稳又活泼、功能模块既独立又联动、数据展示既精确又直观。
1.2 ArkUI 声明式 UI 范式深度解析

HarmonyOS 的 ArkUI 框架是华为面向全场景多设备的一套 UI 开发框架,其核心特征是声明式编程范式。与传统的命令式 UI(通过 findViewById 获取控件再逐个设置属性)截然不同,声明式 UI 让开发者只需描述"界面应该是什么样子",框架负责将状态变化高效地映射到视图更新。这种范式大幅减少了样板代码,使 UI 代码的可读性和可维护性显著提升。
在 ArkUI 中,组件化是构建界面的基本思路。每一个 @Component 修饰的 struct 就是一个可复用的 UI 单元,它拥有独立的状态和构建逻辑。组件之间通过参数传递和事件回调进行通信,形成清晰的层级关系。本应用中,整个页面被封装为单一的 @Entry @Component,内部通过一系列 @Builder 方法将头部、子导航、各内容页、弹框、底部栏等拆分为可组合的构建块。这种设计虽然在一个 struct 内完成,但通过 Builder 的拆分实现了逻辑上的组件化,便于阅读和维护。
1.3 装饰器体系:ArkUI 的状态管理骨架

ArkUI 提供了多层状态装饰器,每一种都有明确的适用场景和传播规则:
@State 是最基础的状态装饰器,用于组件内部可变状态。当其值改变时,框架自动触发该组件的重新渲染——这个过程是精确的差量更新,而非全量重绘。本应用定义了 18 个 @State 变量,覆盖导航索引、弹框开关、表单数据、列表数据和动画计数器等全部 UI 状态。每当用户点击 Tab 切换、打开弹框、输入文字或提交表单时,对应的 @State 变量被修改,ArkUI 框架立即检测到变化并重新渲染依赖该变量的 UI 区域。
@Observed 用于标注可观察的类。被 @Observed 修饰的类,其实例的属性变化可以被框架追踪。本应用的五个数据模型类(WishItem、BillItem、ChallengeItem、SavePost、NoteItem)均使用 @Observed 标注,确保当愿望的已存金额、账单的金额、挑战的参与状态等属性变化时,绑定了该对象的列表项会自动刷新。@Observed 通常与 @ObjectLink 配合使用实现跨组件观察,但本应用由于在单组件内完成,主要依赖 @State 数组的引用变化来触发列表更新。
@Builder 是 ArkUI 独有的视图构建装饰器。它不是状态管理装饰器,而是用于将一段 UI 构建逻辑封装为可复用的方法。@Builder 方法可以接收参数,可以在方法内部访问组件的 @State 变量,可以在条件分支和循环中使用。本应用定义了 14 个 @Builder 方法,涵盖了特效层、头部、子导航、五个内容页面、个人中心、四个弹框、遮罩层、底部栏和内容路由等所有 UI 区域。这种通过 Builder 拆分视图的方式,是 ArkUI 在单组件场景下实现"逻辑组件化"的核心手段。
@Entry 标记组件为页面入口,每个 ArkUI 页面有且仅有一个 @Entry 组件。@Component 声明一个自定义组件结构体。两者组合 @Entry @Component 定义了应用的根页面。
1.4 与 React/Flutter 的对比分析

ArkUI 的声明式范式与 React 的 JSX 和 Flutter 的 Widget 树在理念上有相似之处,但在实现细节上各有特色:
React 使用虚拟 DOM 进行差量对比,状态变化后通过 diff 算法计算最小变更集再批量提交到真实 DOM。ArkUI 则直接在 C++ 层维护组件树,状态变化通过观察者模式精确通知到依赖该状态的组件,跳过了虚拟 DOM 的 diff 过程,理论上更新效率更高。此外,React 的状态管理需要借助 useState/useReducer 等 Hook,而 ArkUI 的 @State 是编译期的装饰器,由框架自动生成观察和更新的样板代码。
Flutter 使用 Widget 树 + Element 树 + RenderObject 树三层架构,Widget 是不可变的配置描述,每次 setState 都会创建新的 Widget 对象再与 Element 树进行 diff。ArkUI 的组件结构体本身是可变的,@State 变量的修改直接触发组件方法重新执行,模型更简单直接。Flutter 的优势在于自绘引擎的跨平台一致性,ArkUI 的优势在于与 HarmonyOS 系统能力的深度集成。
在本应用中,一个值得对比的细节是动画实现方式。React 常用 react-spring 或 framer-motion 实现动画,Flutter 使用 AnimationController + Tween,而本应用采用的是"setInterval 驱动 tick 计数器 + 纯函数计算坐标"的方式——这是一种更接近"命令式动画"在声明式框架中的混合实践。虽然帧率不高(约 9 FPS),但配合取模算法的跳跃式坐标变化,视觉上形成了独特的"像素艺术"风格金币飘落效果,且实现成本极低,无需引入额外的动画库。
1.5 金融记账场景的技术挑战

金融理财类应用在技术实现上面临几个独特的挑战:
数据精度:金额计算不能有浮点误差。本应用在静态数据中使用整数和小数混合(如 8.5 元),在实际工程中应使用分(cent)为单位存储,前端显示时再除以 100。本应用作为演示版本,直接使用 number 类型存储金额,在 amountText 函数中使用 toFixed(1) 格式化,这在模拟数据场景下足够使用。
视觉信任感:金融应用必须通过视觉传递可靠、安全的感受。本应用采用 Material Design 蓝色系作为主色,头部使用深蓝渐变,这些色彩在招商银行、支付宝等成熟应用中已被验证能建立专业信任。同时,所有金额数字使用粗体大号字体,传递"数字即权威"的视觉暗示。
信息密度与可读性的平衡:账单页面需要展示收支对比图表、消费结构分布、流水列表等多种信息,如何在有限的移动端屏幕上组织这些内容而不显杂乱?本应用的策略是"卡片分区+纵向堆叠",每个信息块用白色卡片包裹,卡片间用 12 的间距分隔,背景用 #F4F7FB 的浅灰蓝形成层次。
动画与功能的共存:金币特效覆盖在整个界面上方,但绝不能干扰用户的点击和滑动操作。本应用通过 hitTestBehavior(HitTestMode.None) 让特效层"穿透"点击事件,这是 ArkUI 提供的命中测试控制能力,在 React 中需要手动设置 pointer-events: none CSS 属性实现类似效果。
1.6 关键技术决策

本应用有几个值得深入讨论的技术决策:
第一,色彩系统采用接口约束的常量对象,而非散落的魔法字符串。ColorPalette 接口定义了 16 个颜色字段,COLORS 常量实现该接口。这保证了全局视觉一致性,编辑器提供自动补全和类型检查,避免拼写错误导致的视觉偏差。这是一种工程化的色彩管理思维,远优于在各处硬编码十六进制字符串。
第二,所有数据模型用 @Observed 标注并配备构造函数,为未来接入真实后端 API 做好了准备。构造函数的参数与类的字段一一对应,使得从 JSON 数据创建模型实例只需一行 new WishItem(data.id, data.name, ...) 即可完成。
第三,弹框系统采用"遮罩层 + 内容卡"的 Stack 叠加模式,通过布尔状态变量控制显隐,简洁而高效。四个弹框共享同一个遮罩层 Builder,点击遮罩层会关闭所有弹框,交互逻辑统一。
第四,特效层设置 hitTestBehavior(HitTestMode.None),确保底层动画不拦截用户交互,这在游戏化视觉与功能可用性之间取得了平衡。同时,特效层在 Stack 中的层级位于内容区和弹框之间,弹框打开时自然遮盖特效。
第五,ForEach 键值函数的精心设计。例如愿望列表的键值为 'w' + w.id.toString() + w.saved.toString(),将 saved 纳入键值,确保当某个愿望的已存金额变化时,对应列表项会被正确更新而非复用旧渲染。这是对 ArkUI 列表渲染机制的深入理解。
二、整体架构流程图
在深入代码之前,先用多张架构流程图展示整个应用的结构全景。从底层数据模型到顶层视图组装,每一层都有清晰的职责边界。
2.1 系统分层架构图
从架构图可以清晰看到,应用遵循"数据 → 工具 → 组件 → 组装"的自底向上构建路径。数据基础层提供色彩规范、类型定义和模拟数据,是整个应用的根基;工具函数层是无状态的纯函数集合,负责数值计算和坐标运算,可被任意组件复用;组件构建层是 UI 的核心,包含特效、头部、五个内容页面和个人中心页;弹框系统作为独立的交互模块,由状态变量驱动显隐;最终所有构建块在视图组装层的 build 方法中通过 Stack 叠加为完整界面。
2.2 特效层动画流程图
这张流程图展示了特效层的动画驱动机制。aboutToAppear 启动 110 毫秒间隔的定时器,每次触发将 tick 加 1。由于 tick 是 @State 变量,框架检测到变化后触发 fxLayer Builder 重新执行。在重新执行过程中,七枚金币分别调用 coinX、coinY、coinA 计算新坐标和透明度,存钱罐调用 haloScale 和 haloA 计算缩放和透明度,最终更新到视觉层,循环往复形成持续动画。
2.3 弹框系统状态转换图
弹框系统的状态转换图展示了四种弹框的生命周期。每个弹框从"关闭态"出发,由特定的用户操作触发打开。弹框打开后,用户可以通过提交表单、点击取消按钮或点击遮罩层三种方式关闭。提交时会经过校验,校验通过则执行数据操作并关闭,校验失败则保持打开状态。所有弹框共享同一个遮罩层,点击遮罩层会将四个布尔状态全部置为 false,确保任何时刻最多只有一个弹框可见。
2.4 数据流图
数据流图展示了应用中数据的来源和流向。静态数据源(WISH_LIST、SAVE_POSTS)在组件初始化时赋值给 @State 变量(wishList、postList),成为可变状态。用户操作通过 unshift(头部插入)、splice(替换或删除)修改这些数组,数组引用变化触发 ArkUI 框架重新渲染对应的列表 UI。其他静态数据(BILL_LIST、CHALLENGE_LIST、NOTE_LIST)直接在 ForEach 中引用,不经过 @State 中转,因为它们在应用运行期间不需要被修改。
三、色彩体系设计
3.1 ColorPalette 接口定义
应用首先定义了一个 ColorPalette 接口,将所有颜色字段以强类型的方式约束起来:
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
gold: string;
}
这个接口定义了 16 个颜色字段,覆盖了主色系、强调色系、背景色系、文本色系、语义色系和装饰色系六大类别。使用接口约束的好处在于:当其他开发者引用 COLORS 帖量时,编辑器能提供自动补全和类型检查,避免拼写错误导致的视觉偏差。如果某个字段名拼写错误,TypeScript 编译器会立即报错,将问题拦截在编译期而非运行期。这是一种工程化的色彩管理思维,远优于在各处硬编码十六进制字符串。
接口中的每个字段都有明确的语义命名。primary 系列代表品牌主色及其明暗变体;accent 系列代表强调色及其浅色变体;bg 和 cardBg 分别代表页面背景和卡片背景;textPrimary、textSecondary、textHint 构成三级文本色阶;border 是分隔线色;success、warning、danger 是三种语义状态色;white 用于深色背景上的文字和图标;gold 是特殊的装饰色,与攒钱主题契合。
3.2 COLORS 常量逐色分析
const COLORS: ColorPalette = {
primary: '#1565C0',
primaryLight: '#E3F2FD',
primaryDark: '#0D47A1',
accent: '#FF8F00',
accentLight: '#FFF8E1',
bg: '#F4F7FB',
cardBg: '#FFFFFF',
textPrimary: '#10233B',
textSecondary: '#5C7189',
textHint: '#AEBDD0',
border: '#E2EAF3',
success: '#2E7D32',
warning: '#F9A825',
danger: '#D32F2F',
white: '#FFFFFF',
gold: '#FBC02D'
};
下面逐一分析每个色彩的 RGB 值、视觉感受和使用场景:
primary #1565C0(深蓝):RGB(21, 101, 192)。这是 Material Design 蓝色系中的 800 色阶,蓝色值 192 远高于红色 21 和绿色 101,形成纯粹的深蓝调。视觉上传递专业、可信赖、冷静的心理感受,是银行和金融机构的首选色。应用头部渐变的终点色、主按钮背景、选中态标签文字色均使用此色,建立"金融应用"的第一印象。在心理学上,深蓝能降低用户的心率感知,使其在查看资产数据时保持冷静,避免因数字波动产生冲动决策。
primaryLight #E3F2FD(浅蓝):RGB(227, 242, 253)。蓝色的 50 色阶,三通道值均很高,形成极浅的蓝白色。视觉上柔和、清爽,不会与白色背景产生强对比。用于选中态背景、信息提示底色(如愿望图标背景)、挑战列表的已加入按钮背景。与 primary 形成同色系明暗对比,在视觉上柔和过渡。
primaryDark #0D47A1(藏蓝):RGB(13, 71, 161)。蓝色的 900 色阶,比 primary 更深。红色值仅 13,几乎不可见,蓝色值 161 低于 primary 的 192,但整体明度更低。用于头部渐变的起始色,营造由深到浅的光照效果——仿佛光线从左上方照射,头部左上角最暗,右下方逐渐变亮。这种渐变模拟了银行 App 中常见的"金属质感"大卡片效果。
accent #FF8F00(琥珀橙):RGB(255, 143, 0)。红色拉满 255,绿色 143 居中,蓝色为 0,形成温暖的琥珀橙色。视觉上充满活力、引导注意力,在大量蓝色背景中形成视觉焦点。用于进度条、挑战卡渐变终点、挑战按钮背景、挑战天数选择器选中态。橙色与蓝色互补,在色彩理论中是最强对比色对之一,能有效引导用户视线到关键交互区域。
accentLight #FFF8E1(米黄):RGB(255, 248, 225)。三通道值均接近 255,形成极浅的暖黄色。视觉上温暖、包裹感强,用于愿望图标背景、挑战弹框头部底色、笔记列表中勋章标签背景。它比纯白多了一份温度,使包含它的区域看起来更亲切。
bg #F4F7FB(灰蓝白):RGB(244, 247, 251)。三通道值很高但蓝通道略高,形成略带冷色调的灰白色。视觉上比纯白更柔和,长时间阅读不刺眼。作为全局背景色,与卡片白色形成约 4% 的亮度差,使卡片自然"浮起"形成层次感。
cardBg #FFFFFF(纯白):RGB(255, 255, 255)。卡片背景色,是最干净纯粹的背景。所有信息内容都承载在白色卡片上,确保内容可读性最大化。白色与 bg 的亮度差虽然仅约 4%,但在移动端屏幕上足以形成清晰的视觉分层。
textPrimary #10233B(墨蓝黑):RGB(16, 35, 59)。不是纯黑(#000000)而是带蓝调的深色,与 primary 色系统一。纯黑在移动端 OLED 屏幕上过于刺眼,而墨蓝黑在保持高对比度的同时更加柔和。用于所有主要文字,如标题、金额数字、用户昵称等。
textSecondary #5C7189(灰蓝):RGB(92, 113, 137)。明度介于 textPrimary 和 textHint 之间,用于说明文字、副标题、辅助信息。如"目标 ¥2999 · 截止 12月31日"这类信息使用此色,与主标题形成层次但不喧宾夺主。
textHint #AEBDD0(浅灰蓝):RGB(174, 189, 208)。明度较高,用于时间戳、极弱化的辅助信息。如"5分钟前""32145阅读"这类不影响阅读主内容的元数据。在视觉层次中位于最底层。
border #E2EAF3(极浅灰蓝):RGB(226, 234, 243)。几乎不可见的分隔线色。用于底部导航栏顶部的 1px 分隔线,提供微妙的边界感而不形成视觉割裂。
success #2E7D32(深绿):RGB(46, 125, 50)。Material Design 绿色 800 色阶,用于收益金额、已达成状态、正向收益提示。绿色在金融语境中代表正向、增长、安全,如"基金收益分红 +¥86.4"使用此色。
warning #F9A825(金黄):RGB(249, 168, 37)。视觉上明亮温暖,预留用于提醒场景。本应用当前未在主要 UI 中使用此色,但保留在色彩体系中以备扩展需求。
danger #D32F2F(红色):RGB(211, 47, 47)。Material Design 红色 700 色阶,用于删除按钮文字、连续打卡天数标签、打卡动态的连续天数标签背景。红色在金融语境中代表警示、支出、风险。注意本应用的"删除"操作按钮文字使用 danger 红色,但删除弹框的确认按钮使用 primary 蓝色,这是有意的色彩区分——按钮文字用红色提示风险,确认按钮用蓝色保持整体调性统一。
white #FFFFFF(白色):RGB(255, 255, 255)。用于深色背景上的文字和图标,如头部渐变区域的所有文字、挑战卡顶部的白色标题等。
gold #FBC02D(金色):RGB(251, 192, 45)。装饰色,RGB 中红色 251 和绿色 192 都很高,蓝色仅 45,形成明亮的金黄色。用于金币主题、子导航栏选中态下方的圆点高亮。金色与攒钱主题高度契合——金币本身就是金黄色的,这个色彩的应用让整个界面的"攒钱"意象更加具象化。
整套色彩体系的核心策略是"蓝色铺底 + 橙金点睛":大面积的蓝色建立金融信任感,局部的橙色和金色引导用户关注攒钱目标和进度,绿色提示正向收益,红色警示风险操作。这种配色在招商银行、支付宝等成熟金融应用中屡见不鲜,但通过 Emoji 图标的加入和金币特效的叠加,又赋予了年轻化的活力。
四、数据模型层
应用定义了五个 @Observed 数据模型类,分别对应愿望清单、账单记录、攒钱挑战、打卡动态和理财笔记。@Observed 装饰器使类的实例成为可观察对象,当其属性变化时,绑定了该对象的 UI 组件会自动刷新。
4.1 愿望清单模型 WishItem
@Observed
export class WishItem {
id: number = 0
name: string = ''
icon: string = ''
target: number = 0
saved: number = 0
deadline: string = ''
constructor(id: number, name: string, icon: string, target: number, saved: number, deadline: string) {
this.id = id; this.name = name; this.icon = icon; this.target = target
this.saved = saved; this.deadline = deadline
}
}
WishItem 描述一个攒钱愿望,是应用最核心的数据模型。id 是唯一标识,用于 ForEach 的键值生成,确保列表项的正确复用和更新。name 是愿望名称,如"Switch 2 游戏机"“北海道旅行基金”,使用自然语言而非编码,更贴近用户的日常表达。
icon 使用 Emoji 表情而非图片资源,这大幅减小了包体积同时保持跨设备一致性——Emoji 在所有 HarmonyOS 设备上都能正确渲染,无需考虑不同分辨率和 DPR 的适配问题。每个愿望配有语义相关的 Emoji,如游戏机配🎮、旅行配✈️、宠物配🐱,视觉识别度高。
target 和 saved 分别是目标金额和已存金额,两者之差即为"还差多少"。这两个字段是数值类型,在进度条计算中通过 percentOf(saved, target) 计算百分比。当 saved >= target 时,进度条变绿并显示"已达成"标记。
deadline 是截止日期,用自然语言描述如"12月31日"“明年5月”“长期”"已达成"而非时间戳。这种设计更符合年轻人的阅读习惯——"明年5月"比"2026-05-01"更直觉。但代价是无法做日期排序和提醒计算,实际工程中应同时存储一个 deadlineTimestamp 字段用于逻辑计算,deadline 字段仅用于显示。
构造函数中采用分号连接的紧凑赋值写法,this.id = id; this.name = name; ...,将六个赋值语句压缩在两行内,减少了代码行数但保持了可读性。@Observed 装饰器在编译期会为类的每个属性生成观察者代码,当属性被赋值时自动通知依赖该属性的 UI 组件更新。
4.2 账单模型 BillItem
@Observed
export class BillItem {
id: number = 0
title: string = ''
category: string = ''
amount: number = 0
date: string = ''
income: boolean = false
constructor(id: number, title: string, category: string, amount: number, date: string, income: boolean) {
this.id = id; this.title = title; this.category = category; this.amount = amount
this.date = date; this.income = income
}
}
BillItem 描述一条账单流水。category 字段存储消费分类,如"餐饮"“交通”“理财”“居住”“娱乐”“日用”“闲置”“工资”,与 CATEGORY_DIST 静态数据中的分类对应,用于消费结构可视化。这种分类设计参考了主流记账应用的常用分类法,覆盖了年轻人日常消费的主要场景。
amount 是金额字段,使用 number 类型。实际工程中应使用整数分(cent)为单位避免浮点误差,本应用作为演示使用元为单位,接受小数如 8.5 元。金额从 6 元(地铁通勤)到 12500 元(工资入账)跨度很大,能充分测试金额显示的适配性。
income 布尔值区分收入和支出,渲染时据此选择不同的图标(💰表示收入,💸表示支出)和金额颜色(绿色表示收入,深色表示支出)。这种"一个布尔值驱动两种渲染"的设计简洁高效,但在实际工程中如果收支类型更复杂(如转账、退款、代付),应改为枚举类型。
date 采用"08-22"的短格式,省略了年份(默认当前年),适合在列表项中紧凑展示。日期字段还参与账单列表的排序(按日期降序),但在本应用中静态数据已按日期排好,无需前端排序。
title 是账单标题,如"早餐·豆浆油条"“奶茶·杨枝甘露”“外卖·麻辣香锅”,比干巴巴的"餐饮消费"更有生活气息。这种文案风格是本应用"年轻化"定位的体现——让记账不再是冰冷的数据录入,而是生活记录的一部分。
4.3 挑战模型 ChallengeItem
@Observed
export class ChallengeItem {
id: number = 0
name: string = ''
members: number = 0
totalSaved: number = 0
days: number = 0
joined: boolean = false
constructor(id: number, name: string, members: number, totalSaved: number, days: number, joined: boolean) {
this.id = id; this.name = name; this.members = members; this.totalSaved = totalSaved
this.days = days; this.joined = joined
}
}
ChallengeItem 描述一个攒钱挑战。name 是挑战名称,如"365天存钱法"“奶茶戒断大作战”“工资到账先存30%”“带饭一周挑战”,这些名称来自社交媒体广泛传播的攒钱方法论,用户一看就知道挑战内容。
members 和 totalSaved 是社区维度的数据,展示参与人数和累计攒钱总额。这两个数字通常很大(如 88421 人、28600000 元),因此在前端使用 amountText 函数格式化为"万"单位显示,如"8.8万人参与"“累计攒下2860.0万”。大数字的格式化是金融应用的基本功——直接显示原始数字会导致位数过多难以阅读。
days 表示挑战已进行天数,用于计算进度条宽度。barH(c.days, 365) 将天数相对于 365 天归一化为百分比,展示挑战的时间进度。这个字段在用户加入挑战后应随时间递增,但本应用作为演示版本使用静态值。
joined 标记当前用户是否已加入该挑战,控制按钮显示"加入"(橙色,可点击)还是"已加入"(灰色,不可点击)。点击"加入"按钮会向打卡动态列表插入一条"加入了挑战"的动态,实现社区互动反馈。joined 是布尔类型,在 ArkUI 中 @Observed 类的布尔属性变化也能触发 UI 更新,使得按钮状态切换即时响应。
4.4 打卡动态模型 SavePost
@Observed
export class SavePost {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
amount: number = 0
streak: number = 0
likes: number = 0
time: string = ''
constructor(id: number, nick: string, avatar: string, text: string, amount: number, streak: number, likes: number, time: string) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.amount = amount; this.streak = streak; this.likes = likes; this.time = time
}
}
SavePost 描述一条社区打卡动态。nick 是用户昵称,如"攒钱小能手"“奶茶绝缘体”“带饭大师”“闲鱼倒爷”——每个昵称都带有人设标签,让虚拟用户有记忆点。avatar 是头像 Emoji,与昵称语义关联,如"定投钉子户"配📈、"抠门艺术家"配🎨。
text 是动态正文,支持最多两行显示并带省略号(通过 maxLines(2) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 实现),避免过长内容破坏卡片布局。文案充满年轻人的表达方式,如"同事都馋哭了"“猫粮基金今日+50,年糕我来了”“发现超市临期区宝藏,3块钱买了两盒草莓”,营造了活跃的社区氛围。
streak 是连续打卡天数,用红色标签背景 #FDECEA 强调,激发用户的坚持欲望——看到别人连续打卡 210 天,自己也会产生"我也要坚持"的心理。amount 是本次存入金额,likes 是点赞数。这三个字段组合在动态底部以"🪙 88元 · 🧡 46 · 5分钟前"的形式展示,信息密度高但层次清晰。
time 是发布时间,使用相对时间格式如"5分钟前"“2小时前”“刚刚”,比绝对时间更直觉。在用户发起新挑战或加入挑战时,新动态的 time 设为"刚刚",符合新发布内容的特征。id 设为 998 或 997 等临时值,避免与静态数据的 id 冲突。
4.5 理财笔记模型 NoteItem
@Observed
export class NoteItem {
id: number = 0
title: string = ''
author: string = ''
tag: string = ''
reads: number = 0
desc: string = ''
constructor(id: number, title: string, author: string, tag: string, reads: number, desc: string) {
this.id = id; this.title = title; this.author = author; this.tag = tag
this.reads = reads; this.desc = desc
}
}
NoteItem 描述一篇理财笔记。tag 字段存储内容分类,取值包括"科普"“测评”“心得”“习惯”“方法”,通过 tagColor 函数映射为不同颜色标签——科普和测评类用蓝色(理性客观),心得和习惯类用橙色(温暖分享),方法类用绿色(正向积极)。这种"语义-颜色"映射使笔记列表在视觉上自然分类,用户扫一眼就能定位内容类型。
reads 是阅读量,展示为"32145阅读",用极小字号(8px)弱化处理,避免阅读量数字干扰标题的阅读。desc 是一句话摘要,单行省略显示(通过 maxLines(1) 和 textOverflow 实现),帮助用户在不点开详情的情况下快速判断内容是否值得阅读。
title 和 author 是基本的内容元数据。标题如"工资到账后的黄金10分钟"“记账三个月改变了什么”“五险一金到底怎么算"都是年轻人关心的理财话题,author 如"省钱侦探”“数据党小陈”“心理系阿糖”"HR老王"等昵称暗示了作者的专业背景,增加内容的可信度。
4.6 配置接口体系
除了五个数据模型,应用还定义了六个纯接口用于图表和导航配置:
interface CategoryDist { label: string; value: number; color: string; }
interface MonthChartItem { label: string; save: number; spend: number; }
interface QuickItem { icon: string; label: string; }
interface RankItem { rank: number; nick: string; avatar: string; saved: string; }
interface NavItem { icon: string; label: string; }
CategoryDist 用于消费结构分布,每项包含标签(如"居住")、百分比(如 32)和对应颜色(如 #1565C0)。value 是百分比数值,六个分类的值之和恰好为 100,确保进度条总宽度填满。
MonthChartItem 用于月度攒钱 vs 花钱对比图,save 和 spend 分别代表每月攒下和花掉的金额。数据覆盖 3 月到 8 月共 6 个月,每月数值在 2900 到 5100 之间波动,5100 作为最大值传入 barH 函数用于柱状高度归一化。
QuickItem 是头部快捷入口配置,包含图标 Emoji 和标签文字。RankItem 是排行榜条目,saved 字段直接存储格式化后的字符串如"4.2万",而非原始数值——这是为了避免在前端重复格式化。NavItem 是底部导航条目。
这些接口不带 @Observed,因为它们是静态配置数据,不需要驱动 UI 更新。使用 interface 而非 class 是因为它们只需要类型约束,不需要构造函数和方法。接口在编译后会被完全擦除,不产生运行时开销。
五、静态数据与模拟数据
应用在顶层定义了大量静态常量数组,作为模拟数据源。这些数据覆盖了所有页面所需的内容,使得应用在不接入后端的情况下即可完整运行和展示。这种"模拟数据先行"的开发模式,允许前端独立于后端完成全部 UI 开发和交互测试,在后端 API 就绪后只需替换数据源即可。
5.1 愿望列表 WISH_LIST
const WISH_LIST: WishItem[] = [
new WishItem(1, 'Switch 2 游戏机', '🎮', 2999, 1860, '12月31日'),
new WishItem(2, '北海道旅行基金', '✈️', 12000, 4520, '明年5月'),
new WishItem(3, '给妈妈换新手机', '📱', 3999, 3500, '11月20日'),
new WishItem(4, '降噪耳机', '🎧', 1899, 900, '明年1月'),
new WishItem(5, '应急备用金', '🛡️', 20000, 12800, '长期'),
new WishItem(6, '人体工学椅', '🪑', 2499, 1980, '10月15日'),
new WishItem(7, '演唱会门票', '🎤', 1580, 1580, '已达成'),
new WishItem(8, '宠物年检套餐', '🐱', 1200, 300, '12月')
];
八条愿望数据精心设计,涵盖了年轻人常见的攒钱场景:数码产品(Switch 2、降噪耳机)、旅行(北海道)、孝心(给妈妈换手机)、应急(备用金)、生活品质(人体工学椅)、娱乐(演唱会)、宠物(年检套餐)。这种多样性确保了不同用户都能在列表中找到共鸣。
从数据设计角度看,每条愿望的金额和进度各不相同:第 1 条 1860/2999 = 62%,第 3 条 3500/3999 = 88%,第 7 条 1580/1580 = 100%(已达成),第 8 条 300/1200 = 25%。这种进度的多样性确保了进度条视觉的丰富性——有刚起步的短条、有接近完成的长条、有满格的绿色条。
第 7 条"演唱会门票"的 saved 等于 target,代表已达成,在渲染时进度条会变为绿色并显示"✅ 已达成"。第 5 条"应急备用金"的 deadline 设为"长期",表示没有明确截止日期,这种灵活的日期表达是自然语言日期的优势。
5.2 账单列表 BILL_LIST
const BILL_LIST: BillItem[] = [
new BillItem(1, '工资入账', '工资', 12500, '08-22', true),
new BillItem(2, '早餐·豆浆油条', '餐饮', 8.5, '08-22', false),
new BillItem(3, '地铁通勤', '交通', 6, '08-22', false),
new BillItem(4, '基金定投自动扣款', '理财', 1000, '08-21', false),
new BillItem(5, '外卖·麻辣香锅', '餐饮', 32.8, '08-21', false),
new BillItem(6, '奶茶·杨枝甘露', '餐饮', 18, '08-21', false),
new BillItem(7, '房租转账', '居住', 2600, '08-20', false),
new BillItem(8, '视频会员续费', '娱乐', 25, '08-20', false),
new BillItem(9, '基金收益分红', '理财', 86.4, '08-20', true),
new BillItem(10, '超市采购·一周食材', '日用', 156.7, '08-19', false),
new BillItem(11, '电影票两张', '娱乐', 78, '08-19', false),
new BillItem(12, '闲鱼卖出旧键盘', '闲置', 220, '08-18', true)
];
账单数据包含 12 条记录,涵盖工资、餐饮、交通、理财、居住、娱乐、日用、闲置交易等分类。其中 3 条为收入(工资 12500、基金收益 86.4、闲鱼卖出 220),9 条为支出。收入和支出的比例约为 1:3,符合年轻人"大部分消费、少量收入"的日常结构。
金额从 6 元(地铁通勤)到 12500 元(工资)跨度很大,能充分测试金额显示的适配性——个位数金额和万级金额在同一列表中展示,需要字号和格式化策略的一致性。小金额如 8.5 元保留一位小数,大金额如 12500 元直接显示整数。
每条记录都配有生动的标题,如"奶茶·杨枝甘露"“外卖·麻辣香锅”“超市采购·一周食材”,比干巴巴的"餐饮消费"更有生活气息。标题中的"·"分隔符将品类和具体物品分开,既有分类信息又有个性化描述。日期从 08-22 到 08-18 按时间降序排列,最近的记录在最上方。
5.3 挑战列表 CHALLENGE_LIST
const CHALLENGE_LIST: ChallengeItem[] = [
new ChallengeItem(1, '365天存钱法', 88421, 28600000, 210, true),
new ChallengeItem(2, '奶茶戒断大作战', 12035, 1580000, 45, false),
new ChallengeItem(3, '工资到账先存30%', 45210, 9870000, 90, true),
new ChallengeItem(4, '地铁代替打车', 8932, 762000, 30, false),
new ChallengeItem(5, '带饭一周挑战', 5432, 398000, 14, false),
new ChallengeItem(6, '双十一不动本金', 32876, 5410000, 60, true)
];
挑战数据设计了 6 个不同主题的攒钱挑战,涵盖长期习惯养成(365天存钱法)、消费戒断(奶茶戒断)、储蓄纪律(先存30%)、出行优化(地铁代替打车)、生活技能(带饭挑战)、购物节防御(双十一不动本金)。这些挑战主题来自社交媒体广泛传播的攒钱方法论,用户一看就知道挑战内容。
参与人数从 5432 到 88421 不等,累计攒金额从 398000 到 28600000 不等,这些大数字通过 amountText 函数格式化为"万"单位显示。天数从 14 到 210 不等,通过 barH(c.days, 365) 计算相对于 365 天的进度百分比。3 个标记为已加入(joined: true),3 个未加入,确保两种按钮状态的视觉都有展示。
5.4 社区动态列表 SAVE_POSTS
const SAVE_POSTS: SavePost[] = [
new SavePost(1, '攒钱小能手', '💪', '今日存入88元,距离Switch2还差1139元!', 88, 128, 46, '5分钟前'),
new SavePost(2, '奶茶绝缘体', '🧋', '戒奶茶第45天,省下的钱够买机票了', 22, 45, 89, '20分钟前'),
new SavePost(3, '带饭大师', '🍱', '这周带饭5天,省了187元,同事都馋哭了', 187, 32, 67, '1小时前'),
new SavePost(4, '房租砍价王', '🏠', '和房东谈续租降了200/月,一年省2400', 2400, 3, 102, '2小时前'),
new SavePost(5, '闲鱼倒爷', '♻️', '卖掉三件闲置,回血568元', 568, 15, 78, '3小时前'),
new SavePost(6, '定投钉子户', '📈', '定投第210天,收益率4.2%,继续躺平', 1000, 210, 156, '4小时前'),
new SavePost(7, '抠门艺术家', '🎨', '发现超市临期区宝藏,3块钱买了两盒草莓', 3, 88, 134, '5小时前'),
new SavePost(8, '攒钱买猫', '🐱', '猫粮基金今日+50,年糕我来了', 50, 66, 55, '6小时前')
];
动态数据设计了 8 条社区打卡内容,每条都有独特的昵称和 Avatar Emoji,文案充满年轻人的表达方式。“同事都馋哭了”“猫粮基金今日+50,年糕我来了”"发现超市临期区宝藏"等文案,既有攒钱干货又有生活趣味,营造了活跃的社区氛围。
连续打卡天数从 3 到 210 不等,streak 字段值越大,红色标签越醒目,对用户的激励效果越强。"定投钉子户"的 streak 为 210 天,是列表中最高的,传递"坚持就是胜利"的心理暗示。点赞数从 46 到 156 不等,高点赞的内容通常更有参考价值。
时间从"5分钟前"到"6小时前"递增,形成时间线效果。在总览页面只展示前 3 条(postList.slice(0, 3)),在用户发起新挑战或加入挑战时,新动态会通过 unshift 插入到列表头部,time 设为"刚刚"。
5.5 理财笔记列表 NOTE_LIST
const NOTE_LIST: NoteItem[] = [
new NoteItem(1, '工资到账后的黄金10分钟', '省钱侦探', '习惯', 32145, '发薪日的自动分配流程,先存后花'),
new NoteItem(2, '零钱理财 vs 货币基金全对比', '数据党小陈', '测评', 28673, '七日年化、申购赎回时效实测'),
new NoteItem(3, '记账三个月改变了什么', '攒钱小能手', '心得', 45201, '消费结构可视化后的行为改变'),
new NoteItem(4, '五险一金到底怎么算', ' HR老王', '科普', 19876, '到手工资的计算公式拆解'),
new NoteItem(5, '攒钱目标怎么定才科学', '心理系阿糖', '方法', 15432, 'SMART原则在存钱上的应用'),
new NoteItem(6, '年轻人的第一份保险清单', '保险课代表', '科普', 23987, '意外+医疗的最低配置思路'),
new NoteItem(7, '双11不剁手指南', '省钱侦探', '习惯', 17654, '购物车冷静期与比价工具'),
new NoteItem(8, '副业攒钱可行吗', '打工人阿凯', '心得', 12908, '时间成本与收益的真实账本')
];
理财笔记数据包含 8 条内容,涵盖工资管理、理财对比、记账心得、社保科普、目标设定、保险配置、消费控制、副业评估等年轻人关心的理财话题。每篇笔记的 tag 取值为"习惯"“测评”“心得”“科普”“方法”,通过 tagColor 函数映射为蓝、橙、绿三种颜色标签。
阅读量从 12908 到 45201 不等,高阅读量的内容通常更受社区欢迎。“记账三个月改变了什么"以 45201 阅读量居首,说明"行为改变"类内容最受关注。作者昵称如"省钱侦探”“数据党小陈”“心理系阿糖”“HR老王”“保险课代表”"打工人阿凯"暗示了作者的专业背景——侦探善于发现省钱机会、数据党擅长量化分析、心理系懂行为动机、HR懂社保、保险课代表懂保险、打工人懂副业——增加内容的可信度和专业感。
5.6 图表与导航配置数据
const CATEGORY_DIST: CategoryDist[] = [
{ label: '居住', value: 32, color: '#1565C0' },
{ label: '餐饮', value: 26, color: '#FF8F00' },
{ label: '理财', value: 18, color: '#2E7D32' },
{ label: '娱乐', value: 12, color: '#FBC02D' },
{ label: '交通', value: 7, color: '#6D4C41' },
{ label: '其他', value: 5, color: '#AEBDD0' }
];
消费结构分布数据中,六个分类的百分比之和恰好为 100(32+26+18+12+7+5=100),每个分类配有独立颜色。颜色与 COLORS 常量中的色系保持一致但略有扩展:居住用 primary 蓝、餐饮用 accent 橙、理财用 success 绿、娱乐用 gold 金、交通用棕色 #6D4C41(COLORS 中没有棕色,这是扩展色)、其他用 textHint 灰。棕色的引入增加了色彩层次,使六个分类各具辨识度。
const MONTH_CHART: MonthChartItem[] = [
{ label: '3月', save: 3200, spend: 4800 },
{ label: '4月', save: 3600, spend: 4400 },
{ label: '5月', save: 2900, spend: 5100 },
{ label: '6月', save: 4200, spend: 3900 },
{ label: '7月', save: 4500, spend: 3600 },
{ label: '8月', save: 3800, spend: 4100 }
];
月度图表数据覆盖 3 月到 8 月共 6 个月,每月的 save 和 spend 数值在 2900 到 5100 之间波动。5100 是六个月中的最大值,作为 barH 函数的 max 参数用于柱状高度归一化。数据设计上,5 月花钱最多(5100)、7 月攒钱最多(4500),呈现了"攒钱和花钱此消彼长"的波动趋势,8 月攒钱率 48% 是当前月的最新数据。
const QUICK_ITEMS: QuickItem[] = [
{ icon: '💰', label: '存一笔' },
{ icon: '📝', label: '记一笔' },
{ icon: '🎯', label: '新目标' },
{ icon: '⚔️', label: '挑战' }
];
const RANK_LIST: RankItem[] = [
{ rank: 1, nick: '定投钉子户', avatar: '📈', saved: '4.2万' },
{ rank: 2, nick: '房租砍价王', avatar: '🏠', saved: '3.8万' },
{ rank: 3, nick: '攒钱小能手', avatar: '💪', saved: '3.1万' }
];
const NAV_LIST: NavItem[] = [
{ icon: '🏠', label: '首页' },
{ icon: '🧾', label: '账单' },
{ icon: '⚔️', label: '挑战' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['总览', '愿望', '账单', '挑战', '笔记'];
快捷入口的四个按钮分别对应存钱(💰存一笔)、记账(📝记一笔)、新建目标(🎯新目标)和发起挑战(⚔️挑战),其中"新目标"和"挑战"绑定了弹框打开逻辑,"记一笔"切换到账单 Tab,"存一笔"未绑定操作(预留扩展)。
排行榜数据中 saved 字段直接存储格式化后的字符串"4.2万",而非原始数值 42000——这是为了避免在前端重复格式化。排行榜数据在总览页展示前三名,同时在发起挑战弹框中复用为"虚位以待"的占位头像。
底部导航的四个主 Tab 对应应用的四大功能域:首页(综合入口)、账单(收支记录)、挑战(社区互动)、我的(个人中心)。子导航的五个标签(总览、愿望、账单、挑战、笔记)仅在首页主 Tab 下显示,实现"首页五内容 Tab"的布局。
六、工具函数体系
应用在顶层定义了八个纯函数,它们不依赖任何组件状态,接收参数返回计算结果,是高度可复用的工具层。纯函数的优势在于可测试性——给定相同的输入必定得到相同的输出,无需模拟组件上下文。
6.1 柱状高度计算 barH
function barH(v: number, max: number): number {
return Math.round(100 * v / max);
}
barH 将一个数值 v 相对于最大值 max 归一化为 0-100 的整数百分比,用于柱状图和进度条的高度/宽度计算。Math.round 确保返回整数,避免浮点数导致的渲染亚像素模糊。
这个函数在账单页的月度图表中用于计算双柱高度:barH(m.save, 5100) 和 barH(m.spend, 5100),5100 是六个月中的最大值。在消费结构条形图中用于计算每条进度条宽度:barH(c.value, 32),32 是六个分类中的最大值。在挑战页用于计算挑战时间进度条宽度:barH(c.days, 365),365 是挑战的最大天数。
使用统一的归一化函数保证了所有柱状图和进度条的视觉比例一致——无论数据范围是 0-32 还是 0-5100,最终渲染的宽度百分比都在 0-100 之间,使得不同图表的视觉规模可比较。
6.2 进度百分比 percentOf
function percentOf(saved: number, target: number): number {
if (target <= 0) {
return 0;
}
return Math.round(100 * saved / target);
}
percentOf 计算已存金额占目标金额的百分比。与 barH 不同,它增加了 target <= 0 的边界保护,避免除零错误——当目标金额为 0 或负数时直接返回 0。这是一个防御性编程的实践,确保函数在异常输入下不会产生 NaN 或 Infinity。
当 saved 超过 target 时,返回值可能大于 100(如 percentOf(1580, 1580) 返回 100,但 percentOf(2000, 1000) 返回 200)。因此调用方在渲染进度条时需要做上限处理。在愿望页中,通过条件判断 percentOf(w.saved, w.target) >= 100 来切换"已达成"状态和绿色进度条,而非截断百分比——100% 和 200% 在视觉上都是满格,但状态判断需要区分"是否达成"而非"超出多少"。
6.3 标签配色 tagColor
function tagColor(t: string): string {
if (t === '科普' || t === '测评') {
return COLORS.primary;
}
if (t === '心得' || t === '习惯') {
return COLORS.accent;
}
return COLORS.success;
}
tagColor 根据笔记标签返回对应的背景色。"科普"和"测评"类内容使用蓝色 COLORS.primary,传递理性、客观、可信赖的心理暗示——这类内容通常是数据分析或知识普及,蓝色符合其"冷静分析"的调性。“心得"和"习惯"类内容使用橙色 COLORS.accent,传递温暖、分享、个人化的心理暗示——这类内容是个人经验分享,橙色的温度感更贴合。其余标签(如"方法”)默认使用绿色 COLORS.success,传递正向、积极、可执行的心理暗示。
这种"语义-颜色"映射使笔记列表在视觉上自然分类。用户扫一眼标签颜色就能判断内容类型:蓝标签是知识科普、橙标签是经验分享、绿标签是方法论。这种色彩编码在金融内容社区中尤为有效——用户在海量信息中快速定位自己需要的内容类型。
6.4 金额格式化 amountText
function amountText(a: number): number {
if (a >= 10000) {
return (a / 10000).toFixed(1) + '万';
}
return a.toString();
}
amountText 将大数字格式化为"万"单位字符串。当数值达到一万时,除以一万并保留一位小数,拼接"万"后缀。如 88421 变为"8.8万",28600000 变为"2860.0万",45210 变为"4.5万"。当数值不足一万时,直接转为字符串返回。
这个函数在挑战卡片的参与人数和累计攒金额展示中使用。大数字直接显示"28600000"位数过多难以阅读,格式化为"2860.0万"后信息密度更高。注意返回类型声明为 number 但实际返回 string(当 a >= 10000 时返回 string,否则也返回 a.toString() 即 string),这在 ArkUI 的类型系统中由于运行时宽松而不会报错,但从严谨性角度应修正为 string 返回类型。这是一个技术债务,在实际工程中应修正。
6.5 金币坐标计算 coinX / coinY
function coinX(tick: number, i: number): number {
return 30 + ((tick * 13 + i * 151) % 620);
}
function coinY(tick: number, i: number): number {
return 20 + ((tick * 17 + i * 83) % 680);
}
coinX 和 coinY 是金币特效的核心坐标计算函数。它们接收当前 tick 值和金币索引 i,通过取模运算生成坐标值。coinX 的返回值范围是 30 到 650(30 + 620),coinY 的返回值范围是 20 到 700(20 + 680)。
使用不同的乘数确保每枚金币的运动轨迹不同:tick * 13 中 13 是 X 坐标的 tick 系数,i * 151 中 151 是金币索引系数。13 和 151 都是质数,选择质数作为乘数可以减少周期性重合,使金币的运动轨迹更加不规律。同理,coinY 使用 17 和 83 作为乘数,也是质数。
取模基数 620 和 680 约等于屏幕宽高,使金币在屏幕范围内运动。由于取模运算的特性,当 tick 持续递增时,坐标值会在 0 到基数之间循环变化,但由于乘数和偏移的综合作用,循环周期很长,视觉上表现为不规律的飘散运动。
6.6 金币透明度计算 coinA
function coinA(tick: number, i: number): number {
if ((tick + i * 3) % 4 === 0) {
return 0.9;
}
return 0.25;
}
coinA 控制金币的透明度。当 (tick + i * 3) % 4 === 0 时返回 0.9(高亮),否则返回 0.25(暗淡)。这意味着每 4 个 tick 中有 1 个 tick 金币处于高亮状态,3 个 tick 处于暗淡状态,形成闪烁效果。
i * 3 的偏移确保每枚金币的高亮时刻不同——金币 0 在 tick % 4 === 0 时高亮,金币 1 在 (tick + 3) % 4 === 0 即 tick % 4 === 1 时高亮,金币 2 在 tick % 4 === 2 时高亮。这样七枚金币不会同时高亮或同时暗淡,而是错落闪烁,视觉效果更自然。
6.7 光晕缩放与透明度 haloScale / haloA
function haloScale(tick: number): number {
return 1 + ((tick % 8) * 0.06);
}
function haloA(tick: number): number {
if (tick % 8 > 4) {
return 0.35;
}
return 0.12;
}
haloScale 在 1.0 到 1.42 之间循环缩放。tick % 8 的值在 0 到 7 之间循环,乘以 0.06 后得到 0 到 0.42 的增量,加上 1.0 得到 1.0 到 1.42 的缩放比例。8 个 tick 构成一个完整的缩放周期:从 1.0 逐渐增大到 1.42,然后跳回 1.0 重新开始。这种"锯齿波"式的缩放配合 110ms 的 tick 间隔,形成"呼吸光晕"效果——存钱罐仿佛在缓慢地一呼一吸。
haloA 在周期的前半段(tick % 8 的值为 0 到 4)返回低透明度 0.12,后半段(值为 5 到 7)返回高透明度 0.35。这意味着存钱罐先暗后亮,配合 haloScale 的先小后大,形成"从暗处逐渐变亮变大"的脉冲效果,仿佛存钱罐在发光吸引金币。
这两个函数配合 coinA 的闪烁,营造出金币环绕存钱罐的灵动氛围。这种基于取模的伪随机算法虽然不是真正的随机数(它有确定的周期和模式),但在视觉效果上足够自然,且确定性使得动画可复现——在相同 tick 值下,金币位置和透明度完全相同,便于调试。
七、组件主体结构
7.1 状态变量定义
@Entry
@Component
struct PagePocketSave {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State challengeOpen: boolean = false
@State editIdx: number = 0
@State editName: string = ''
@State editTarget: string = ''
@State editDeadline: string = ''
@State addName: string = ''
@State addTarget: string = ''
@State addIcon: string = '🎯'
@State challengeName: string = ''
@State challengeDays: string = ''
@State wishList: WishItem[] = WISH_LIST
@State postList: SavePost[] = SAVE_POSTS
@State fxTimer: number = -1
组件定义了 18 个 @State 变量,可分为四组:
导航状态组:mainTab 控制底部四个主 Tab 的当前选中项(0=首页、1=账单、2=挑战、3=我的),subTab 控制首页下五个内容 Tab 的选中项(0=总览、1=愿望、2=账单、3=挑战、4=笔记)。这两个变量的改变会触发 mainContent Builder 中的条件分支重新执行,实现页面切换。当 mainTab 为 0 时,先渲染头部和子导航,再根据 subTab 选择五个内容页之一;其他 mainTab 值直接渲染对应页面。
弹框状态组:addOpen、editOpen、delOpen、challengeOpen 是四个布尔值,分别控制四种弹框的显示与隐藏。任何一个为 true 时,build 方法中对应的条件渲染分支就会创建弹框层。设计为四个独立布尔值而非一个枚举,是因为同时最多只有一个弹框打开,但每个弹框有自己的打开和关闭逻辑,独立布尔值更灵活。
表单数据组:editIdx、editName、editTarget、editDeadline 服务于编辑目标弹框,addName、addTarget、addIcon 服务于新增愿望弹框,challengeName、challengeDays 服务于发起挑战弹框。这些变量在弹框打开时被初始化(openAdd/openEdit/openChallenge 方法中),在用户输入时通过 TextInput 的 onChange 回调更新,在提交时被 doAdd/doEdit/doChallenge 方法读取。
数据与特效组:wishList 和 postList 分别持有愿望列表和动态列表的可变副本(初始值来自静态数据 WISH_LIST 和 SAVE_POSTS),当用户执行新增、编辑、删除操作时,这两个数组会被修改并触发列表重新渲染。tick 是动画计数器,每 110ms 自增 1。fxTimer 存储 setInterval 的 ID,用于在组件销毁时清理定时器。
7.2 生命周期
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
}, 110);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear 是 ArkUI 组件的生命周期回调,在组件创建后、渲染前调用。这里启动一个 110 毫秒间隔的定时器,每次触发将 tick 加 1。由于 tick 是 @State 变量,每次自增都会触发依赖它的特效层 Builder 重新渲染,从而驱动金币和光晕动画。
110 毫秒的间隔约为 9 FPS(1000/110 ≈ 9.09),虽然帧率不高,但配合取模算法的跳跃式坐标变化,视觉上形成了"金币闪烁飘落"的独特效果,而非平滑动画。这是一种有意为之的"像素艺术"风格选择——平滑的物理动画反而不符合"金币"这种离散物体的视觉特性,而跳跃式的位置变化更像金币在空中翻滚下落。
aboutToDisappear 在组件销毁前调用,清理定时器以防止内存泄漏。clearInterval(this.fxTimer) 取消定时器,然后将 fxTimer 置为 -1 作为"已清理"标记。这是 ArkUI 生命周期管理的最佳实践——每一个在 aboutToAppear 中创建的资源,都应在 aboutToDisappear 中释放。如果忘记清理定时器,组件销毁后定时器仍会继续执行,引用已销毁组件的 this.tick,导致内存泄漏和潜在的运行时错误。
ArkUI 的生命周期回调还包括 aboutToReuse(组件复用时调用)和 onPageShow/onPageHide(页面显示/隐藏时调用),本应用由于是单页面且不需要复用,仅使用了最基本的生命周期对。
7.3 打开弹框函数
openAdd(): void {
this.addName = '';
this.addTarget = '';
this.addIcon = '🎯';
this.addOpen = true;
}
openAdd 在打开新增愿望弹框前重置表单字段为初始值,确保每次打开都是干净的输入状态。addName 和 addTarget 清空为空字符串,addIcon 默认设为目标 Emoji ‘🎯’。最后将 addOpen 置为 true 触发弹框渲染。这种"先重置再打开"的模式确保了用户每次打开弹框不会看到上次的残留输入。
openEdit(index: number): void {
if (index >= 0 && index < this.wishList.length) {
this.editIdx = index;
this.editName = this.wishList[index].name;
this.editTarget = this.wishList[index].target.toString();
this.editDeadline = this.wishList[index].deadline;
}
this.editOpen = true;
}
openEdit 接收愿望列表的索引,先做边界检查(index >= 0 && index < this.wishList.length,防止越界访问导致运行时错误),然后将对应愿望的数据填充到编辑表单字段中,实现"回显"功能。editName 设为愿望名称,editTarget 将数字 target 转为字符串(因为 TextInput 的 text 参数需要字符串类型),editDeadline 设为截止日期。最后打开编辑弹框。
openDel(): void {
this.delOpen = true;
}
openChallenge(): void {
this.challengeName = '';
this.challengeDays = '';
this.challengeOpen = true;
}
openDel 仅设置 delOpen 为 true,因为删除弹框不需要表单数据。openChallenge 重置挑战名称和天数字段后打开弹框,逻辑与 openAdd 类似。
7.4 执行操作函数
doAdd(): void {
if (this.addName.length > 0 && this.addTarget.length > 0) {
this.wishList.unshift(new WishItem(999, this.addName, this.addIcon, parseInt(this.addTarget), 0, '未设置'));
}
this.addOpen = false;
}
doAdd 先校验愿望名称和目标金额非空(length > 0),然后使用 unshift 将新愿望插入到列表头部。unshift 而非 push 的选择是有意的——新愿望在列表头部,使其在 UI 上第一时间可见,给用户即时的视觉反馈。新愿望的 id 设为 999(临时值),saved 初始为 0,deadline 设为"未设置"。parseInt 将文本输入的字符串转为整数。无论校验是否通过,最后都会关闭弹框(this.addOpen = false)——校验失败时弹框关闭但数据未插入,实际工程中应给用户提示。
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.wishList.length) {
const old = this.wishList[this.editIdx];
this.wishList.splice(this.editIdx, 1, new WishItem(old.id, this.editName, old.icon, parseInt(this.editTarget), old.saved, this.editDeadline));
}
this.editOpen = false;
}
doEdit 使用 splice 的替换模式(第二个参数为 1 表示删除 1 个元素并插入新元素),用编辑后的新 WishItem 替换原数组中对应位置的元素。注意它保留了原愿望的 id、icon 和 saved(已存金额),只更新了名称、目标金额和截止日期。这种"部分更新"策略避免了编辑目标时意外清零已存进度——用户修改目标金额时,已存金额保持不变。
doDel(): void {
if (this.wishList.length > 0) {
this.wishList.splice(0, 1);
}
this.delOpen = false;
}
doDel 删除列表的第一个元素(索引 0)。这是一个简化实现——实际应用中应根据当前选中项的索引删除,但作为演示版本,它展示了删除操作的完整流程:检查列表非空 → 执行 splice 删除 → 关闭弹框。
doChallenge(): void {
if (this.challengeName.length > 0) {
this.postList.unshift(new SavePost(998, '我', '🙋', '发起了挑战「' + this.challengeName + '」,求监督!', 0, 0, 0, '刚刚'));
}
this.challengeOpen = false;
}
doChallenge 在挑战名称非空时,向动态列表头部插入一条"我"发起挑战的打卡动态。文案中嵌入挑战名称"发起了挑战「奶茶戒断30天」,求监督!“,模拟社区互动效果。这条动态的 id 为 998(临时值),streak 和 likes 均为 0,time 为"刚刚”,符合新发布内容的特征。
八、特效层详解
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
Text('🪙')
.fontSize(i % 3 === 0 ? 15 : 11)
.opacity(coinA(this.tick, i))
.translate({ x: coinX(this.tick, i), y: coinY(this.tick, i) })
}, (i: number) => 'coin' + i.toString())
Text('🐷')
.fontSize(22)
.opacity(haloA(this.tick))
.scale({ x: haloScale(this.tick), y: haloScale(this.tick) })
.translate({ x: 540, y: 560 })
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
特效层是整个应用最具视觉记忆点的部分。它使用 Stack 容器叠加两组元素:七枚金币 Emoji 和一只存钱罐 Emoji。
8.1 金币飘落动画原理
金币部分通过 ForEach 渲染索引 0-6 共七枚金币。每枚的字号在 15 和 11 之间交替(i % 3 === 0 时为 15,否则为 11),形成大小差异——索引 0、3、6 的金币较大(15px),索引 1、2、4、5 的金币较小(11px),模拟近大远小的透视效果。
opacity 调用 coinA(this.tick, i),使每枚金币在不同时刻呈现高亮(0.9)或暗淡(0.25)状态,产生闪烁效果。由于 coinA 函数中 (tick + i * 3) % 4 的偏移设计,七枚金币的高亮时刻错落分布,不会同时闪烁,视觉效果更自然。
translate 调用 coinX 和 coinY 计算坐标,使金币在 620x680 的范围内不断跳动。每次 tick 自增 1,所有金币的坐标都会跳变到新位置,配合 110ms 的 tick 间隔,形成"金币飘散下落"的动态视觉。由于取模运算的特性,坐标变化是跳跃式的而非连续的,这形成了独特的"闪烁飘落"效果,不同于物理引擎驱动的平滑下落动画。
ForEach 的键值函数返回 'coin' + i,确保金币在 tick 变化时是原地更新而非重建。如果键值随 tick 变化(如返回 'coin' + i + tick),ArkUI 会认为列表项发生变化而销毁旧组件创建新组件,导致性能下降。稳定的键值让框架仅更新现有组件的属性(opacity 和 translate),提升渲染效率。
8.2 存钱罐光晕动画原理
存钱罐部分固定在坐标 (540, 560) 位置——通过 translate({ x: 540, y: 560 }) 实现。opacity 由 haloA 控制,scale 由 haloScale 控制,两者都以 8 个 tick 为周期循环变化。
haloScale 从 1.0 逐渐增大到 1.42(8 tick 周期),模拟存钱罐"变大"的过程,仿佛在充气膨胀。haloA 在前半周期(tick % 8 <= 4)返回 0.12(暗淡),后半周期返回 0.35(明亮),形成"从暗到亮"的过渡。两者配合形成"呼吸光晕"效果——存钱罐在明暗之间脉动,尺寸在 1.0 到 1.42 之间缩放,仿佛在发光吸引金币。
存钱罐的字号固定为 22px,比金币(11-15px)更大,使其在视觉上是"中心焦点",金币是"环绕粒子"。这种主次关系符合攒钱主题——存钱罐是目标,金币是积累过程。
8.3 穿透点击的设计
最关键的设计是 hitTestBehavior(HitTestMode.None),这行代码使整个特效层不参与命中测试,即所有触摸事件会穿透特效层到达下方的功能层。这确保了金币动画虽然覆盖在界面上方,但不会干扰用户点击按钮、滑动列表等操作。
HitTestMode 是 ArkUI 提供的命中测试模式枚举,有四个值:Default(默认,参与命中测试)、Block(阻断后续节点的命中测试)、Transparent(透明区域也参与命中测试)、None(完全不参与命中测试)。本应用选择 None 是因为特效层纯装饰性,不应接收任何点击事件。
这是游戏化视觉与功能可用性之间的精妙平衡——如果特效层拦截点击,用户每次点击按钮时可能点到金币上导致操作失败,严重损害用户体验。通过 HitTestMode.None,特效层成为"视觉装饰但不影响交互"的透明层。
8.4 Stack 层级设计
特效层在 build 方法中被放置在内容区和底部栏之上(Stack 的第二层),但位于弹框层之下。这意味着:
- 弹框打开时,弹框层在特效层之上,自然遮盖特效
- 特效层在功能层之上,金币动画覆盖在内容上方
- 由于
HitTestMode.None,点击穿透到功能层 - 视觉上金币飘落在内容之上,但不影响操作
这种三层 Stack 结构(功能层 → 特效层 → 弹框层)是本应用架构的核心设计之一。
九、头部区域详解
@Builder
header() {
Column({ space: 12 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('🙋')
.fontSize(30)
.padding(8)
.backgroundColor('rgba(255,255,255,0.25)')
.borderRadius(20)
Text('尊享 Lv.6')
.fontSize(8)
.fontColor('#BBDEFB')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('我的总资产 (元)')
.fontSize(10)
.fontColor('#BBDEFB')
Text('86,420.58')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('本月已攒 +¥4,500 · 收益率 3.2%')
.fontSize(10)
.fontColor('#BBDEFB')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 6 }) {
Text('🔍')
.fontSize(16)
Text('明细')
.fontSize(9)
.fontColor('#BBDEFB')
}
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.subTab = 2;
})
}
.width('100%')
Row({ space: 8 }) {
ForEach(QUICK_ITEMS, (q: QuickItem) => {
Column({ space: 4 }) {
Text(q.icon)
.fontSize(18)
Text(q.label)
.fontSize(9)
.fontColor('#E3F2FD')
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.backgroundColor('rgba(255,255,255,0.16)')
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
if (q.label === '新目标') {
this.openAdd();
} else if (q.label === '挑战') {
this.openChallenge();
} else if (q.label === '记一笔') {
this.subTab = 2;
}
})
}, (q: QuickItem) => q.label)
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 18 })
.linearGradient({
angle: 135,
colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]]
})
}
头部区域采用银行 App 经典的"深蓝渐变大卡片"风格,整体结构分为上下两部分。
9.1 上半部分:三列资产展示
上半部分是一个三列布局的 Row:
左列是用户头像和等级。🙋 Emoji 配 8 的内边距和半透明白色圆形背景(rgba(255,255,255,0.25)),圆角 20 形成圆形头像效果。下方是"尊享 Lv.6"等级标签,8px 的极小字号配浅蓝色 #BBDEFB 文字,弱化等级信息但不缺失。等级系统的设计增加了游戏化元素,激励用户持续使用。
中列使用 layoutWeight(1) 占据剩余空间,展示三行信息:第一行是"我的总资产 (元)"标签,10px 浅蓝色文字;第二行是"86,420.58"的大号白色粗体金额,24px 字号配合 FontWeight.Bold,是整个头部最醒目的信息——这个数字代表用户的全部资产,是金融应用最核心的数据;第三行是"本月已攒 +¥4,500 · 收益率 3.2%"的辅助信息,10px 浅蓝色文字,提供增量数据。
三列的色彩层次分明:头像使用半透明白色,主文字使用纯白,辅助文字使用浅蓝 #BBDEFB,在深蓝渐变背景上形成"白 → 浅蓝 → 深蓝"的视觉层次。这种层次设计让用户视线自然聚焦到白色大号金额上,符合金融应用"数字即核心"的设计理念。
右列是搜索图标🔍和"明细"文字,点击后跳转到账单 Tab(this.subTab = 2)。这提供了一条从头部直达账单详情的快捷路径,用户查看资产后想了解明细时无需翻到底部导航。
9.2 下半部分:快捷入口栏
下半部分是四个快捷入口按钮,横向等宽排列(每个 layoutWeight(1)),使用半透明白色背景(rgba(255,255,255,0.16))和 12 的圆角。四个按钮分别是"💰存一笔"“📝记一笔”“🎯新目标”“⚔️挑战”。
点击逻辑通过标签文字判断(q.label):"新目标"打开新增愿望弹框(this.openAdd()),"挑战"打开发起挑战弹框(this.openChallenge()),"记一笔"切换到账单 Tab(this.subTab = 2),"存一笔"未绑定操作(预留扩展)。这种基于标签文字的条件分支虽然不如枚举类型严谨,但在静态数据场景下足够清晰。
快捷入口栏的设计让用户在头部就能触达四个核心操作,无需翻找底部导航——这是银行 App 常见的"高频操作前置"设计模式。
9.3 渐变背景设计
整个头部使用 linearGradient 从 primaryDark(#0D47A1)到 primary(#1565C0)的 135 度渐变填充。angle: 135 表示渐变方向从左上到右下,colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]] 表示从 0% 位置的深蓝到 100% 位置的浅蓝。
这种渐变模拟了从左上方光源照射的效果——左上角最暗(primaryDark),右下方逐渐变亮(primary),形成金属质感的深蓝大卡片。这里没有使用动画,是纯静态渐变,符合银行风格头部"沉稳可靠"的设计取向。
头部的 padding 设置为 { left: 16, right: 16, top: 14, bottom: 18 },底部的 bottom: 18 留出了较大间距,与下方子导航栏形成呼吸感。左右 16 的内边距是移动端常见的安全边距,确保内容不被屏幕边缘裁切。
十、子导航栏详解
@Builder
subNav() {
Row() {
ForEach(SUB_NAV_LIST, (t: string, idx: number) => {
Column({ space: 4 }) {
Text(t)
.fontSize(this.subTab === idx ? 14 : 12)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
if (this.subTab === idx) {
Text('●')
.fontSize(5)
.fontColor(COLORS.gold)
} else {
Text(' ')
.fontSize(5)
.fontColor('transparent')
}
}
.layoutWeight(1)
.padding({ top: 8, bottom: 6 })
.backgroundColor(this.subTab === idx ? COLORS.primary : 'transparent')
.borderRadius(16)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.subTab = idx;
})
}, (t: string) => t)
}
.width('100%')
.padding({ left: 12, right: 12, top: 8 })
.backgroundColor(COLORS.bg)
}
子导航栏实现"余额圆环高亮式"的 Tab 切换效果。五个标签(总览、愿望、账单、挑战、笔记)横向等宽排列,每个 layoutWeight(1) 自适应宽度。
10.1 选中态的五维视觉强化
选中项的视觉处理包含五个维度的变化:
-
字号放大:选中时 14px,未选中 12px,形成 2px 的差异。字号差异在移动端是显著的视觉变化,足以让用户一眼识别当前 Tab。
-
字重加粗:选中时
FontWeight.Bold,未选中FontWeight.Normal。加粗的文字在视觉上更"重",与字号放大配合强化选中态的存在感。 -
文字颜色反转:选中时白色(在蓝色背景上),未选中灰色
textSecondary。颜色的反转是最强烈的视觉变化——从灰色变为白色配蓝色背景,形成明显的对比。 -
背景色填充:选中时填充
primary蓝色并圆角 16,形成"胶囊"效果。圆角 16 配合layoutWeight(1)的等宽布局,使选中 Tab 呈现为蓝色圆角矩形,与未选中 Tab 的透明背景形成鲜明对比。 -
金色圆点:选中时在文字下方显示一个 5px 的金色
●,未选中时用透明色('transparent')占位保持高度一致。
10.2 金色圆点的点睛之笔
金色圆点的设计是子导航栏的点睛之笔——它呼应了金币主题,在蓝色胶囊下方点缀一抹金色,既是选中状态的视觉标记,也是攒钱主题的隐喻。每当用户切换 Tab,金色圆点会从一个 Tab 跳到另一个 Tab,视觉上像一枚金币在移动。
使用透明色占位(Text(' ').fontSize(5).fontColor('transparent'))而非条件渲染高度,确保了 Tab 切换时不会出现布局跳动。如果选中态多一个圆点而未选中态少一个元素,Tab 的整体高度会变化,导致下方内容区的位置跳动,影响用户体验。透明色占位保持了所有 Tab 的高度一致,切换时只有颜色和背景变化,没有尺寸变化。
10.3 点击交互
点击任何 Tab 时,onClick 回调将 this.subTab 设为对应的 idx,触发 ArkUI 重新渲染子导航栏和内容区。由于 subTab 是 @State 变量,框架自动检测到变化,重新执行依赖 subTab 的条件分支,切换显示对应的内容页面。
整个子导航栏的背景使用 COLORS.bg(#F4F7FB),与头部渐变和内容区卡片形成"深蓝头部 → 浅灰蓝导航 → 白色卡片"的三级色彩层次。padding({ left: 12, right: 12, top: 8 }) 提供了适度的内边距,使 Tab 之间和与上下区域都有呼吸感。
十一、总览页面详解
@Builder
pageOverview() {
Column({ space: 12 }) {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text('💰 零钱罐')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('¥12,860')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('七日年化 2.31%')
.fontSize(9)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
Text('🎯 目标进度')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('62%')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('心愿7/8个进行中')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Column({ space: 4 }) {
Text('🔥 连续打卡')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('128天')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
Text('超过98%的攒友')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Column({ space: 10 }) {
Text('🏆 攒钱月度榜')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
ForEach(RANK_LIST, (r: RankItem) => {
Row({ space: 10 }) {
Text(r.rank === 1 ? '🥇' : (r.rank === 2 ? '🥈' : '🥉'))
.fontSize(18)
Text(r.avatar)
.fontSize(20)
.padding(4)
.backgroundColor(COLORS.primaryLight)
.borderRadius(12)
Text(r.nick)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(r.saved)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.width('100%')
}, (r: RankItem) => 'rk' + r.rank.toString())
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
Column({ space: 10 }) {
Text('📅 存钱日历 · 8月')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
ForEach([0, 1, 2, 3], (row: number) => {
Row({ space: 6 }) {
ForEach([1, 2, 3, 4, 5, 6, 7], (d: number) => {
Text((row * 7 + d).toString())
.fontSize(10)
.fontColor((row * 7 + d) % 3 === 0 ? COLORS.white : COLORS.textSecondary)
.padding(4)
.backgroundColor((row * 7 + d) % 3 === 0 ? COLORS.accent : COLORS.bg)
.borderRadius(8)
.layoutWeight(1)
.textAlign(TextAlign.Center)
}, (d: number) => 'cal' + (row * 7 + d).toString())
}
.width('100%')
}, (row: number) => 'row' + row.toString())
Text('● 已存钱日 · 本月存钱22天')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
ForEach(this.postList.slice(0, 3), (p: SavePost) => {
Row({ space: 10 }) {
Text(p.avatar)
.fontSize(22)
.padding(5)
.backgroundColor(COLORS.accentLight)
.borderRadius(14)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(p.nick)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('连续' + p.streak.toString() + '天')
.fontSize(8)
.fontColor(COLORS.danger)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.backgroundColor('#FDECEA')
.borderRadius(6)
}
Text(p.text)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('🪙 ' + p.amount.toString() + '元 · 🧡 ' + p.likes.toString() + ' · ' + p.time)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}, (p: SavePost) => 'po' + p.id.toString() + p.time)
}
.width('100%')
}
总览页是首页的默认内容页,信息密度最高。它由四个区块从上到下排列:
11.1 资产三卡片区
三张白色卡片等宽并排,分别展示零钱罐余额(💰零钱罐 ¥12,860,蓝色,七日年化 2.31% 绿色)、目标进度(🎯目标进度 62%,橙色,心愿7/8个进行中)、连续打卡天数(🔥连续打卡 128天,红色,超过98%的攒友)。
每张卡片采用"标题 → 大数字 → 辅助说明"的三层结构,颜色编码与内容语义一致:零钱罐用蓝色(对应 primary 主色,传递银行信任感),目标进度用橙色(对应 accent 强调色,引导注意力),连续打卡用红色(对应 danger 危险色,传递紧迫感——“128天不能断”)。layoutWeight(1) 确保三卡等宽,14 的圆角和 12 的内边距营造舒适的卡片比例。
11.2 攒钱月度榜
白色卡片内展示前三名排行榜。每行包含奖牌 Emoji(🥇🥈🥉,根据 r.rank 值选择)、头像 Emoji(配 primaryLight 浅蓝背景圆角 12 包裹)、昵称(layoutWeight(1) 自适应宽度)和攒钱金额(primary 蓝色粗体)。视觉重点从左到右递减——奖牌最醒目,金额次之,昵称填充中间空间。
ForEach 的键值函数返回 'rk' + r.rank.toString(),以排名作为键值,确保排行榜项的正确复用。
11.3 存钱日历
展示 8 月的 4 行 7 列共 28 天的日历网格。外层 ForEach 遍历行索引 0-3,内层 ForEach 遍历列索引 1-7,日期计算为 row * 7 + d(1 到 28)。每隔 3 天(% 3 === 0)的日期用橙色高亮背景表示"已存钱日",文字为白色;其余日期用灰背景,文字为灰色。
这个日历虽然简化(没有对齐真实星期,28 天也不完整),但直观传达了"持续打卡"的概念——用户一眼就能看到本月哪些天存了钱,橙色的密集程度反映了存钱频率。底部标注"● 已存钱日 · 本月存钱22天"提供汇总信息。
11.4 打卡动态流
使用 this.postList.slice(0, 3) 取前 3 条动态展示。slice(0, 3) 是数组的切片方法,返回前三个元素的新数组,不修改原数组。
每条动态是横向布局:左侧是头像 Emoji(22px,配 accentLight 米黄背景圆角 14 包裹),右侧是三行信息。第一行是昵称加连续天数标签——连续天数标签使用红色文字配 #FDECEA 浅红背景圆角 6,8px 字号,紧凑而醒目。第二行是动态文案,11px 字号配 textSecondary 灰色,maxLines(2) 限制最多两行,textOverflow({ overflow: TextOverflow.Ellipsis }) 超出部分显示省略号。第三行是底部信息行,格式为"🪙 88元 · 🧡 46 · 5分钟前",9px 字号配 textHint 浅灰,信息密度高但层次清晰。
ForEach 的键值函数返回 'po' + p.id.toString() + p.time,将 id 和 time 组合作为键值,确保动态列表的正确复用和更新。
十二、愿望页面详解
@Builder
pageWish() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🎯')
.fontSize(18)
Text('7个心愿进行中 · 1个已达成')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text('+ 新愿望')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(16)
ForEach(this.wishList, (w: WishItem, idx: number) => {
Column({ space: 10 }) {
Row({ space: 10 }) {
Text(w.icon)
.fontSize(26)
.padding(8)
.backgroundColor(COLORS.accentLight)
.borderRadius(14)
Column({ space: 3 }) {
Text(w.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('目标 ¥' + w.target.toString() + ' · 截止 ' + w.deadline)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text('编辑')
.fontSize(10)
.fontColor(COLORS.primary)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(10)
.onClick(() => {
this.openEdit(idx);
})
Text('删除')
.fontSize(10)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor('#FDECEA')
.borderRadius(10)
.onClick(() => {
this.openDel();
})
}
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Column()
.width('100%')
.height(10)
.backgroundColor(COLORS.bg)
.borderRadius(5)
Column()
.width(percentOf(w.saved, w.target) + '%')
.height(10)
.backgroundColor(percentOf(w.saved, w.target) >= 100 ? COLORS.success : COLORS.accent)
.borderRadius(5)
}
.width('100%')
Row({ space: 8 }) {
Text('已存 ¥' + w.saved.toString())
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('还差 ¥' + (w.target - w.saved > 0 ? (w.target - w.saved).toString() : '0'))
.fontSize(10)
.fontColor(COLORS.textSecondary)
Text(percentOf(w.saved, w.target) >= 100 ? '✅ 已达成' : percentOf(w.saved, w.target).toString() + '%')
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}, (w: WishItem) => 'w' + w.id.toString() + w.saved.toString())
}
.width('100%')
}
愿望页面是应用的核心功能页,展示用户的攒钱目标列表。
12.1 顶部状态栏
顶部是浅蓝色(primaryLight)的状态栏,显示🎯图标、“7个心愿进行中 · 1个已达成"的状态文字和右侧蓝色”+ 新愿望"按钮。状态文字使用 layoutWeight(1) 填充中间空间,"新愿望"按钮使用 primary 蓝色背景圆角 14,点击调用 this.openAdd() 打开新增愿望弹框。这个状态栏提供了页面内容的汇总信息和创建新愿望的入口。
12.2 愿望卡片结构
每个愿望卡片的视觉层次精心设计,分为三层:
头部行:左侧是 26px 的大号 Emoji 图标,配 accentLight 米黄色背景圆角 14 包裹,形成醒目的愿望图标。中间是愿望名称(13px 粗体墨蓝黑)和"目标 ¥2999 · 截止 12月31日"的辅助信息(9px 浅灰蓝),使用 layoutWeight(1) 填充空间。右侧是垂直排列的"编辑"和"删除"两个小按钮——"编辑"使用 primary 蓝色文字配 primaryLight 浅蓝背景,"删除"使用 danger 红色文字配 #FDECEA 浅红背景,颜色编码明确区分操作性质。
进度条:使用 Stack 叠加两层 Column。底层是灰色背景条(width('100%')、height(10)、backgroundColor(COLORS.bg)、borderRadius(5)),上层是彩色进度条(width(percentOf(w.saved, w.target) + '%')、height(10)、borderRadius(5))。进度条宽度由 percentOf 计算的百分比决定。关键的颜色判断逻辑是 percentOf(w.saved, w.target) >= 100 ? COLORS.success : COLORS.accent——达到或超过 100% 时进度条变绿,否则为橙色。这种颜色变化提供了即时的视觉反馈——绿色代表"达成了",橙色代表"还在路上"。
底部数据行:展示三组信息——“已存 ¥1860”(蓝色粗体)、“还差 ¥1139”(灰色)、百分比状态(灰色右对齐)。当达到 100% 时显示"✅ 已达成"文字。w.target - w.saved > 0 ? (w.target - w.saved).toString() : '0' 的三元判断确保当已存金额超过目标时,"还差"显示为 0 而非负数。
12.3 ForEach 键值设计
ForEach 的键值函数 'w' + w.id.toString() + w.saved.toString() 将 saved 纳入键值。这是一个关键的设计——如果键值仅用 w.id,当某个愿望的 saved 变化时(如用户存了一笔钱),ArkUI 的列表 diff 算法会认为该项的键值未变而复用旧渲染,导致进度条和数据不更新。将 saved 纳入键值后,saved 变化会导致键值变化,框架识别为"新项"而重新渲染。
十三、账单页面详解
@Builder
pageBill() {
Column({ space: 12 }) {
Column({ space: 10 }) {
Text('📊 攒钱 vs 花钱(近6月)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row({ space: 6 }) {
ForEach(MONTH_CHART, (m: MonthChartItem) => {
Column({ space: 4 }) {
Row({ space: 2 }) {
Column()
.width(8)
.height(barH(m.save, 5100))
.backgroundColor(COLORS.primary)
.borderRadius(3)
Column()
.width(8)
.height(barH(m.spend, 5100))
.backgroundColor(COLORS.accentLight)
.borderRadius(3)
}
Text(m.label)
.fontSize(8)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.height(100)
.justifyContent(FlexAlign.End)
.alignItems(HorizontalAlign.Center)
}, (m: MonthChartItem) => m.label)
}
.width('100%')
Row({ space: 12 }) {
Text('■ 攒下')
.fontSize(9)
.fontColor(COLORS.primary)
Text('■ 花掉')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('8月攒钱率 48% ↑')
.fontSize(9)
.fontColor(COLORS.success)
.layoutWeight(1)
.textAlign(TextAlign.End)
}
.width('100%')
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
Column({ space: 8 }) {
Text('🧾 消费结构')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
ForEach(CATEGORY_DIST, (c: CategoryDist) => {
Row({ space: 8 }) {
Text(c.label)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(36)
Stack({ alignContent: Alignment.Start }) {
Column()
.width('100%')
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
Column()
.width(barH(c.value, 32) + '%')
.height(8)
.backgroundColor(c.color)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(c.value.toString() + '%')
.fontSize(10)
.fontColor(COLORS.textPrimary)
.width(28)
}
.width('100%')
}, (c: CategoryDist) => c.label)
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
ForEach(BILL_LIST, (b: BillItem) => {
Row({ space: 10 }) {
Text(b.income ? '💰' : '💸')
.fontSize(18)
.padding(6)
.backgroundColor(b.income ? '#E8F5E9' : COLORS.bg)
.borderRadius(12)
Column({ space: 2 }) {
Text(b.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(b.category + ' · ' + b.date)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text((b.income ? '+' : '-') + '¥' + b.amount.toString())
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(b.income ? COLORS.success : COLORS.textPrimary)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.onClick(() => {
this.openDel();
})
}, (b: BillItem) => 'b' + b.id.toString())
}
.width('100%')
}
账单页面分为三个区块:月度双柱对比图、消费结构条形图、账单流水列表。
13.1 月度双柱对比图
使用 MONTH_CHART 数据渲染 6 个月的双柱状图。每个月份并排展示蓝色"攒下"柱和浅黄"花掉"柱,柱子宽度均为 8px,间距 2px。柱子高度通过 barH(m.save, 5100) 和 barH(m.spend, 5100) 计算,5100 是六个月中的最大值,确保所有柱子高度不超过容器。
容器高度固定为 100 并使用 justifyContent(FlexAlign.End) 使柱子从底部对齐生长——这是柱状图的标准布局方式,柱子从底部向上延伸,最高值到达顶部,短柱子在底部。alignItems(HorizontalAlign.Center) 使柱子和月份标签水平居中。
下方有图例和"8月攒钱率 48% ↑"的成功色提示。攒钱率的计算逻辑是 save / (save + spend) * 100,8 月数据为 3800 / (3800 + 4100) * 100 ≈ 48%,↑ 箭头表示较上月有所提升。
13.2 消费结构条形图
使用 CATEGORY_DIST 数据渲染六条水平进度条。每条的颜色来自数据自身的 color 字段(居住用蓝色、餐饮用橙色、理财用绿色、娱乐用金色、交通用棕色、其他用灰色),与 COLORS 常量中的色系一致但略有扩展。
左侧标签固定 36px 宽度,中间进度条 layoutWeight(1) 自适应,右侧百分比固定 28px 宽度。进度条使用 Stack 叠加两层 Column:底层灰色背景条,上层彩色进度条宽度由 barH(c.value, 32) + '%' 计算(32 是六个分类中的最大值)。这种结构化的横向条形图在移动端比饼图更易阅读——饼图在小屏幕上扇区太小难以辨识,而横向条形图可以充分利用屏幕宽度。
13.3 账单流水列表
遍历 BILL_LIST 渲染 12 条记录。每条记录左侧的图标根据 income 布尔值选择 💰(收入,配 #E8F5E9 浅绿背景)或 💸(支出,配 COLORS.bg 灰背景)。中间是标题(12px 粗体墨蓝黑)和"分类 · 日期"(9px 浅灰蓝),使用 layoutWeight(1) 填充空间。右侧是金额,格式为"+¥12500"或"-¥8.5",收入为绿色加号,支出为深色减号。
点击任意账单项会打开删除确认弹框(this.openDel()),模拟删除操作。这个交互设计让用户长按或点击账单时可以删除记录,但在实际工程中应区分"查看详情"和"删除"两种操作——点击打开详情,长按或滑动触发删除。
十四、挑战页面详解
@Builder
pageChallenge() {
Column({ space: 10 }) {
Column({ space: 6 }) {
Text('⚔️ 组队攒钱,一起坚持')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('参与挑战的攒友平均多存 38%')
.fontSize(10)
.fontColor('#BBDEFB')
Text('发起挑战')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(COLORS.white)
.borderRadius(14)
.onClick(() => {
this.openChallenge();
})
}
.width('100%')
.padding(16)
.linearGradient({
angle: 120,
colors: [[COLORS.primary, 0.0], [COLORS.accent, 1.0]]
})
.borderRadius(16)
.alignItems(HorizontalAlign.Center)
ForEach(CHALLENGE_LIST, (c: ChallengeItem) => {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(c.days.toString() + '天')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('进行中')
.fontSize(8)
.fontColor('#BBDEFB')
}
.width(64)
.height(64)
.backgroundColor(COLORS.primary)
.borderRadius(16)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('👥 ' + amountText(c.members) + '人参与 · 累计攒下 ¥' + amountText(c.totalSaved))
.fontSize(10)
.fontColor(COLORS.textSecondary)
Stack({ alignContent: Alignment.Start }) {
Column()
.width('100%')
.height(6)
.backgroundColor(COLORS.bg)
.borderRadius(3)
Column()
.width(barH(c.days, 365) + '%')
.height(6)
.backgroundColor(COLORS.accent)
.borderRadius(3)
}
.width('100%')
.height(6)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(c.joined ? '已加入' : '加入')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(c.joined ? COLORS.textHint : COLORS.white)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(c.joined ? COLORS.bg : COLORS.accent)
.borderRadius(12)
.onClick(() => {
if (!c.joined) {
this.postList.unshift(new SavePost(997, '我', '🙋', '加入了挑战「' + c.name + '」,day 1 打卡!', 0, 1, 0, '刚刚'));
}
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}, (c: ChallengeItem) => 'ch' + c.id.toString())
}
.width('100%')
}
14.1 顶部渐变引导卡
挑战页面的顶部是一张从蓝色到橙色的 120 度渐变引导卡(linearGradient({ angle: 120, colors: [[COLORS.primary, 0.0], [COLORS.accent, 1.0]] }))。蓝色到橙色的渐变象征着"理性到热情"的过渡,非常符合挑战主题——攒钱本身是理性的,但挑战机制注入了热情和社交激励。
卡片内三行内容:白色粗体标题"⚔️ 组队攒钱,一起坚持"、浅蓝色辅助文字"参与挑战的攒友平均多存 38%"(用数据激励用户参与)、白底蓝字"发起挑战"按钮(点击打开发起挑战弹框)。"平均多存 38%"是一个精心设计的激励文案——用具体数字而非空泛口号,更有说服力。
14.2 挑战列表卡片
挑战列表的每条卡片是三段式布局:
左侧是 64x64 的蓝色方块,展示"天数 + 进行中"。如"210天"用 15px 白色粗体,"进行中"用 8px 浅蓝色。方块使用 justifyContent(FlexAlign.Center) 和 alignItems(HorizontalAlign.Center) 使内容居中。这个方块的设计参考了日历打卡应用中常见的"天数计数器"样式,让用户一眼看到挑战持续了多久。
中间展示挑战名称(13px 粗体墨蓝黑)、参与人数和累计金额(均通过 amountText 格式化为"万"单位),以及一条橙色进度条。进度条宽度由 barH(c.days, 365) 计算即挑战天数占 365 天的比例。使用 layoutWeight(1) 填充中间空间。amountText(c.members) 将 88421 格式化为"8.8万",amountText(c.totalSaved) 将 28600000 格式化为"2860.0万"。
右侧是加入按钮。已加入的显示灰色"已加入"(COLORS.bg 背景 + COLORS.textHint 文字),未加入的显示橙色"加入"(COLORS.accent 背景 + COLORS.white 文字)。按钮使用 borderRadius(12) 和适度的 padding。
14.3 加入挑战的交互
点击未加入的按钮会向 postList 头部插入一条"加入了挑战「365天存钱法」,day 1 打卡!“的打卡动态,实现社区互动反馈。新动态的 id 为 997(临时值),avatar 为🙋,streak 为 1(刚开始打卡),likes 为 0,time 为"刚刚”。这种设计让用户加入挑战后立即在社区动态中看到自己的打卡记录,获得即时的社交反馈。
注意这里的 onClick 回调有 if (!c.joined) 的判断——只有未加入的挑战才能点击加入。但加入后 c.joined 不会变为 true(因为 CHALLENGE_LIST 是静态数据,不是 @State 变量),实际工程中应将挑战列表设为 @State 并在加入时修改 joined 属性。
十五、笔记页面详解
@Builder
pageNote() {
Column({ space: 10 }) {
ForEach(NOTE_LIST, (n: NoteItem) => {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(n.tag)
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(tagColor(n.tag))
.borderRadius(8)
Text(n.reads.toString() + '阅读')
.fontSize(8)
.fontColor(COLORS.textHint)
}
Column({ space: 3 }) {
Text(n.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(n.desc)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('✍ ' + n.author)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('📚')
.fontSize(16)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}, (n: NoteItem) => 'n' + n.id.toString())
}
.width('100%')
}
笔记页面是最简洁的内容页,仅有一个列表。每条笔记卡片是横向三段式布局:
左侧是彩色标签和阅读量。标签文字(如"习惯"“测评”“心得”)使用白色文字配 tagColor(n.tag) 返回的颜色背景,8 的左右内边距和 2 的上下内边距,圆角 8 形成紧凑的标签。颜色由 tagColor 函数决定——科普和测评用蓝色,心得和习惯用橙色,方法用绿色。标签下方是阅读量"32145阅读",8px 极小字号配 textHint 浅灰,弱化处理。
中间是三行文字内容。第一行是标题(12px 粗体墨蓝黑),第二行是单行摘要(10px 灰色,maxLines(1) 限制一行,textOverflow({ overflow: TextOverflow.Ellipsis }) 超出显示省略号),第三行是作者"✍ 省钱侦探"(9px 浅灰)。使用 layoutWeight(1) 填充中间空间。
右侧是📚阅读图标,16px 字号,暗示点击可阅读全文。
整体视觉克制,以文字内容为主,标签颜色是唯一的装饰元素。这种"内容优先"的设计在阅读类应用中是常见做法——减少视觉干扰,让用户专注于内容本身。
十六、个人页面详解
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🙋')
.fontSize(34)
.padding(10)
.backgroundColor(COLORS.primaryLight)
.borderRadius(22)
Column({ space: 4 }) {
Text('攒钱小达人')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('攒龄 128 天 · 累计攒下 ¥86,420')
.fontSize(10)
.fontColor(COLORS.textSecondary)
Row({ space: 4 }) {
Text('🏅 攒钱新星')
.fontSize(8)
.fontColor(COLORS.gold)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(8)
Text('🔥 打卡狂魔')
.fontSize(8)
.fontColor(COLORS.danger)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#FDECEA')
.borderRadius(8)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
Column({ space: 0 }) {
ForEach(['绑定银行卡', '自动攒钱设置', '账单导出', '帮助与反馈'], (t: string) => {
Row({ space: 10 }) {
Text('💳')
.fontSize(16)
Text(t)
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('>')
.fontSize(12)
.fontColor(COLORS.textHint)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.onClick(() => {
this.mainTab = 0;
})
}, (t: string) => t)
}
.padding({ left: 14, right: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('38')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('心愿')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('6')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('挑战')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('128')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
Text('连续打卡')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('12')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
Text('笔记')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
.width('100%')
}
个人页面包含三个区块:用户信息卡、设置菜单列表、统计数据卡。
16.1 用户信息卡
顶部是用户信息卡,圆角 18(比其他卡片的 14-16 更大),传达"个人主页"的专属感。左侧是🙋头像(34px 字号配 primaryLight 浅蓝背景圆角 22 包裹),右侧是三行信息:昵称"攒钱小达人"(15px 粗体)、攒龄和累计攒下金额(10px 灰色)、两个勋章标签——"🏅 攒钱新星"用金色文字配米黄背景,"🔥 打卡狂魔"用红色文字配浅红背景。勋章标签的设计增加了游戏化元素,展示用户的成就。
16.2 设置菜单列表
中间是设置菜单列表,包含"绑定银行卡"“自动攒钱设置”“账单导出”“帮助与反馈"四个选项。每行左侧是💳图标,中间是菜单文字(layoutWeight(1) 填充),右侧是”>"箭头(textHint 浅灰),形成经典的 iOS 风格设置列表。点击任意菜单项会跳回首页(this.mainTab = 0),这是演示版本的简化处理,实际工程中应跳转到对应的设置子页面。
16.3 统计数据卡
底部是四列统计数据卡,分别展示心愿数(38,蓝色)、挑战数(6,橙色)、连续打卡天数(128,红色)、笔记数(12,绿色)。四列等宽排列(layoutWeight(1)),数字使用 15px 粗体,标签使用 9px 浅灰。颜色编码与各功能页的主题色一致——心愿用蓝色(愿望页头部状态栏背景色)、挑战用橙色(挑战页渐变终点色)、打卡用红色(打卡天数标签色)、笔记用绿色(方法标签色)。
十七、弹框系统详解
应用实现了四种风格各异的弹框,每种弹框都有独特的视觉设计和交互逻辑。所有弹框通过 build 方法中的条件渲染(if (this.addOpen)、if (this.editOpen) 等)控制显隐,打开时创建弹框层,关闭时销毁。
17.1 新增愿望弹框(渐变头图卡)
@Builder
addModalBody() {
Column({ space: 0 }) {
Column({ space: 4 }) {
Text('🎯 创建新愿望')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('有目标的人攒钱更快')
.fontSize(10)
.fontColor('#BBDEFB')
}
.width('100%')
.padding(16)
.linearGradient({
angle: 120,
colors: [[COLORS.primaryDark, 0.0], [COLORS.accent, 1.0]]
})
.borderRadius({ topLeft: 20, topRight: 20 })
.alignItems(HorizontalAlign.Center)
Column({ space: 12 }) {
Column({ space: 6 }) {
Text('愿望名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
TextInput({ placeholder: '比如:去一趟新疆旅行' })
.onChange((v: string) => {
this.addName = v;
})
.height(38)
.fontSize(11)
.backgroundColor(COLORS.bg)
.borderRadius(10)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text('目标金额(元)')
.fontSize(11)
.fontColor(COLORS.textSecondary)
TextInput({ placeholder: '0' })
.onChange((v: string) => {
this.addTarget = v;
})
.height(38)
.fontSize(11)
.backgroundColor(COLORS.bg)
.borderRadius(10)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text('选个图标')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Row({ space: 8 }) {
ForEach(['🎯', '🎮', '✈️', '📱', '🐱', '💍'], (ic: string) => {
Text(ic)
.fontSize(18)
.padding(6)
.backgroundColor(this.addIcon === ic ? COLORS.primary : COLORS.bg)
.borderRadius(12)
.onClick(() => {
this.addIcon = ic;
})
}, (ic: string) => ic)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Row({ space: 10 }) {
Text('取消')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.addOpen = false;
})
Text('创建愿望')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(20)
.onClick(() => {
this.doAdd();
})
}
.width('100%')
}
.padding(16)
.backgroundColor(COLORS.cardBg)
}
.width('100%')
.constraintSize({ maxHeight: '80%' })
}
新增愿望弹框采用"渐变头图卡"设计。顶部是从深蓝到橙色的 120 度渐变区域(linearGradient({ angle: 120, colors: [[COLORS.primaryDark, 0.0], [COLORS.accent, 1.0]] })),展示标题"🎯 创建新愿望"和激励文案"有目标的人攒钱更快"。渐变方向 120 度从左下到右上,深蓝在左下角,橙色在右上角,形成温暖向上的视觉感受。borderRadius({ topLeft: 20, topRight: 20 }) 只设置顶部圆角,与下方白色表单区域形成卡片整体感。
下方白色表单区域包含三个输入项和底部按钮行:
愿望名称输入:TextInput 的 placeholder 为"比如:去一趟新疆旅行",onChange 回调将输入值赋给 this.addName。高度 38,字号 11,背景使用 COLORS.bg 浅灰蓝,圆角 10。
目标金额输入:placeholder 为"0",onChange 赋值给 this.addTarget。样式与愿望名称输入一致。
图标选择器:六个 Emoji 候选(🎯🎮✈️📱🐱💍),水平排列。选中项背景为 COLORS.primary 蓝色,未选中为 COLORS.bg 灰色。点击更新 this.addIcon,视觉反馈即时。fontSize(18) 和 padding(6) 使每个图标有足够的触摸区域。
底部按钮行:取消(灰底灰字)和创建愿望(蓝底白字粗体),各占 layoutWeight(1) 等宽。取消按钮点击关闭弹框,创建按钮点击调用 this.doAdd() 提交表单。圆角 20 使按钮呈胶囊形。
constraintSize({ maxHeight: '80%' }) 限制弹框最大高度为屏幕的 80%,防止内容过多时溢出屏幕。
17.2 编辑目标弹框(分段进度卡)
@Builder
editModalBody() {
Column({ space: 14 }) {
Row({ space: 8 }) {
Text('✏️')
.fontSize(16)
Text('编辑攒钱目标')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('')
.layoutWeight(1)
Text('✕')
.fontSize(14)
.fontColor(COLORS.textHint)
.onClick(() => {
this.editOpen = false;
})
}
.width('100%')
Row({ space: 0 }) {
ForEach(['基本信息', '金额', '期限'], (t: string, idx: number) => {
Column({ space: 3 }) {
Text((idx + 1).toString())
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding(5)
.backgroundColor(COLORS.primary)
.borderRadius(10)
Text(t)
.fontSize(9)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (t: string) => t)
}
.width('100%')
Column({ space: 6 }) {
Text('愿望名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
TextInput({ text: this.editName, placeholder: '愿望名称' })
.onChange((v: string) => {
this.editName = v;
})
.height(38)
.fontSize(11)
.backgroundColor(COLORS.bg)
.borderRadius(10)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text('目标金额(元)')
.fontSize(11)
.fontColor(COLORS.textSecondary)
TextInput({ text: this.editTarget, placeholder: '目标金额' })
.onChange((v: string) => {
this.editTarget = v;
})
.height(38)
.fontSize(11)
.backgroundColor(COLORS.bg)
.borderRadius(10)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text('截止日期')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Row({ space: 6 }) {
ForEach(['今年内', '明年', '长期'], (d: string) => {
Text(d)
.fontSize(10)
.fontColor(this.editDeadline === d ? COLORS.white : COLORS.textSecondary)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(this.editDeadline === d ? COLORS.accent : COLORS.bg)
.borderRadius(12)
.onClick(() => {
this.editDeadline = d;
})
}, (d: string) => d)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Row({ space: 10 }) {
Text('放弃编辑')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.editOpen = false;
})
Text('保存目标')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(20)
.onClick(() => {
this.doEdit();
})
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.constraintSize({ maxHeight: '80%' })
}
}
编辑弹框的特色是顶部的三段式进度指示器,展示"基本信息 → 金额 → 期限"三个步骤编号和名称,每步用蓝色圆形数字标记。这种分段进度设计向用户暗示编辑操作是一个有步骤的过程,虽然实际上三个字段同时可编辑,但视觉上的分段让操作显得更有条理。
表单包含名称输入框(TextInput({ text: this.editName }),使用 text 参数而非 placeholder 来回显当前值)、目标金额输入框(同样带回显值)、截止日期选择器(三个快捷选项:今年内、明年、长期,选中态橙色背景 COLORS.accent,未选中灰色背景)。
与新增弹框不同,编辑弹框的截止日期使用快捷选项而非自由输入,这简化了用户操作——大多数攒钱目标的截止日期都可以归入"今年内""明年"或"长期"三类。快捷选项的选中态用橙色背景,与新增弹框的图标选择器选中态用蓝色背景形成区分。
弹框右上角有✕关闭按钮(onClick 设置 editOpen = false),这是新增弹框没有的——编辑弹框允许直接关闭而不保存,而新增弹框只有取消和创建两个按钮。底部是"放弃编辑"和"保存目标"按钮,分别使用灰色和蓝色配色。
整个弹框使用统一的白色背景和 20 圆角,视觉比新增弹框更简洁——新增弹框有渐变头部,编辑弹框没有渐变头部,而是用分段进度指示器替代。
17.3 删除记录弹框(蓝色警示卡)
@Builder
delModalBody() {
Column({ space: 14 }) {
Text('🗑️')
.fontSize(32)
.padding(13)
.backgroundColor(COLORS.primaryLight)
.borderRadius(24)
Text('确认删除这条记录?')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('删除后该记录将从账本中移除且无法恢复,攒钱进度会同步回退')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.lineHeight(17)
Row({ space: 10 }) {
Text('取消')
.fontSize(12)
.fontColor(COLORS.primary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(20)
.onClick(() => {
this.delOpen = false;
})
Text('删除')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(20)
.onClick(() => {
this.doDel();
})
}
.width('100%')
.padding({ top: 4 })
}
.width('86%')
.padding(20)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
删除弹框采用居中式的警示卡片设计,宽度仅为 86%(比其他弹框的 100% 窄),视觉聚焦。整个弹框内容居中对齐(alignItems(HorizontalAlign.Center)),形成"中央对话框"的经典布局。
顶部是大号垃圾桶图标🗑️(32px 字号配 13 的内边距和 primaryLight 浅蓝圆形背景,圆角 24),视觉上明确传达"删除"操作的性质。中间是加粗的确认问题"确认删除这条记录?“(15px 墨蓝黑粗体),下方是灰色说明文字(11px,居中对齐,lineHeight(17) 提供舒适的行间距),特别提醒"删除后该记录将从账本中移除且无法恢复,攒钱进度会同步回退”——这句话既说明了操作的不可逆性,又提醒了删除对攒钱进度的影响,是金融应用删除操作的必要提示。
底部是取消(浅蓝底蓝字)和删除(蓝底白字粗体)两个按钮。注意这里"删除"按钮使用的是 primary 蓝色而非 danger 红色,这与传统的红色删除按钮不同。这个设计选择保持了弹框整体的蓝色基调统一——在银行风格应用中,蓝色是"权威操作"的颜色,使用蓝色而非红色删除按钮,暗示这个操作是经过系统确认的安全操作,而非危险操作。
17.4 发起挑战弹框(排行榜卡)
@Builder
challengeModalBody() {
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('⚔️')
.fontSize(22)
.padding(8)
.backgroundColor(COLORS.accentLight)
.borderRadius(14)
Column({ space: 2 }) {
Text('发起攒钱挑战')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('让全社区的攒友监督你')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.accentLight)
.borderRadius({ topLeft: 20, topRight: 20 })
Column({ space: 12 }) {
Column({ space: 6 }) {
Text('挑战名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
TextInput({ placeholder: '比如:奶茶戒断30天' })
.onChange((v: string) => {
this.challengeName = v;
})
.height(38)
.fontSize(11)
.backgroundColor(COLORS.bg)
.borderRadius(10)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text('挑战天数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Row({ space: 6 }) {
ForEach(['7天', '21天', '100天', '365天'], (d: string) => {
Text(d)
.fontSize(10)
.fontColor(this.challengeDays === d ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.challengeDays === d ? COLORS.accent : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.challengeDays = d;
})
}, (d: string) => d)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Row({ space: 8 }) {
ForEach(RANK_LIST, (r: RankItem) => {
Column({ space: 2 }) {
Text(r.avatar)
.fontSize(18)
Text('虚位以待')
.fontSize(8)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.bg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}, (r: RankItem) => 'mc' + r.rank.toString())
}
.width('100%')
Row({ space: 10 }) {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.bg)
.borderRadius(20)
.onClick(() => {
this.challengeOpen = false;
})
Text('发起挑战')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(20)
.onClick(() => {
this.doChallenge();
})
}
.width('100%')
}
.padding(14)
.backgroundColor(COLORS.cardBg)
}
.width('100%')
.constraintSize({ maxHeight: '80%' })
}
发起挑战弹框采用"排行榜卡"设计,头部使用米黄色背景(COLORS.accentLight)配⚔️剑盾图标,与新增弹框的渐变头部形成风格区分。
表单包含挑战名称输入框和天数快捷选择(7天、21天、100天、365天,选中态橙色背景 COLORS.accent)。天数选项的设计参考了行为心理学中的"21天习惯养成"理论——7天是短期冲刺,21天是习惯养成周期,100天是深度巩固,365天是年度挑战。用户选择不同天数暗示了不同的攒钱决心。
最具创意的是中间的"虚位以待"排行榜占位区——复用了 RANK_LIST 的三个头像位置,但将昵称替换为"虚位以待"灰色文字。这个设计暗示发起挑战后自己的头像将出现在排行榜上,将功能表单与社区竞争元素融合,是一个巧妙的激励机制设计。
底部是"再想想"(灰底灰字)和"发起挑战"(橙底白字粗体)按钮。注意发起挑战按钮使用橙色 COLORS.accent 而非蓝色 COLORS.primary,与新增愿望弹框的蓝色"创建愿望"按钮形成色彩区分——橙色更热烈,符合挑战主题的社交激励调性。
17.5 通用遮罩层
@Builder
modalOverlay() {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(16,35,59,0.55)')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
this.challengeOpen = false;
})
}
所有弹框共享同一个遮罩层。遮罩层是一个全屏的 Column,背景色为半透明的深蓝灰色(rgba(16,35,59,0.55),RGB(16,35,59) 对应 textPrimary 的墨蓝黑色,透明度 0.55 使背景内容半隐半现)。
justifyContent(FlexAlign.Center) 使内容居中对齐——虽然遮罩层本身没有子组件(内容组件在外层 Column 中并列排列),但这个属性确保了如果后续有内容追加时自动居中。
onClick 回调将四个弹框状态全部置为 false,实现"点击遮罩关闭所有弹框"的交互。这是一种"一网打尽"式的关闭策略——无论当前打开了哪个弹框,点击遮罩都会关闭。这种设计的优点是逻辑简单,缺点是无法支持"点击遮罩不关闭"的弹框(如强制确认场景),但本应用所有弹框都是非强制的,适合这种设计。
十八、底部导航与主内容组装
18.1 底部导航栏
@Builder
bottomBar() {
Column({ space: 0 }) {
Column()
.width('100%')
.height(1)
.backgroundColor(COLORS.border)
Row() {
ForEach(NAV_LIST, (n: NavItem, idx: number) => {
Column({ space: 3 }) {
Text(n.icon)
.fontSize(20)
Text(n.label)
.fontSize(9)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 6 })
.onClick(() => {
this.mainTab = idx;
})
}, (n: NavItem) => n.label)
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg)
}
底部导航栏顶部有 1px 的极浅分隔线(height(1) 配 COLORS.border 极浅灰蓝色),与内容区形成微妙的边界感。四个主 Tab 等宽排列(每个 layoutWeight(1)),分别是"🏠首页"“🧾账单”“⚔️挑战”“👤我的”。
选中项的图标下方文字变为 primary 蓝色,未选中为 textHint 浅灰色。注意图标本身不随选中状态变化(保持 Emoji 原色),仅通过文字颜色区分——这种设计较为克制,避免了选中态视觉变化过多。Emoji 图标的彩色特性使其不适合做颜色变化(变色会破坏 Emoji 的辨识度),因此仅通过文字颜色传递选中态。
ForEach 的键值函数返回 n.label,以标签文字作为键值,确保导航项的正确复用。点击任何 Tab 时,onClick 回调将 this.mainTab 设为对应的 idx,触发 ArkUI 重新渲染 mainContent 中的条件分支,切换显示对应的主页面。
底部导航栏使用白色背景(COLORS.cardBg),与内容区的灰色背景形成对比,使导航栏在视觉上"固定"在底部,不会被内容区的滚动内容遮挡。
18.2 内容区路由
@Builder
mainContent() {
Scroll() {
Column() {
if (this.mainTab === 0) {
this.header()
this.subNav()
Column({ space: 10 }) {
if (this.subTab === 0) {
this.pageOverview()
} else if (this.subTab === 1) {
this.pageWish()
} else if (this.subTab === 2) {
this.pageBill()
} else if (this.subTab === 3) {
this.pageChallenge()
} else {
this.pageNote()
}
}
.width('100%')
.padding(14)
} else if (this.mainTab === 1) {
Column({ space: 0 }) {
this.subNav()
}
.width('100%')
Column({ space: 10 }) {
this.pageBill()
}
.width('100%')
.padding(14)
} else if (this.mainTab === 2) {
Column({ space: 10 }) {
this.pageChallenge()
}
.width('100%')
.padding(14)
} else {
Column({ space: 10 }) {
this.pageMine()
}
.width('100%')
.padding(14)
}
}
.width('100%')
}
.layoutWeight(1)
.width('100%')
}
内容区使用 Scroll 包裹,使所有页面内容可滚动。通过 mainTab 和 subTab 的双重条件分支实现页面路由。
首页(mainTab=0) 是最复杂的路由:先渲染头部 header() 和子导航 subNav(),再根据 subTab 选择五个内容页之一(0=总览、1=愿望、2=账单、3=挑战、4=笔记)。内容页外层用 Column({ space: 10 }) 包裹并提供 14 的 padding。
账单页(mainTab=1) 复用子导航 subNav() 并固定显示账单内容 pageBill()。注意这里虽然子导航渲染了,但点击子导航的 Tab 不会切换账单页的内容——因为账单页的路由不依赖 subTab。这是应用的一个设计简化,实际工程中账单页可以不显示子导航或让子导航只显示"账单"高亮。
挑战页(mainTab=2) 直接展示挑战大厅 pageChallenge(),不需要头部和子导航。
我的页(mainTab=3) 展示个人中心 pageMine(),同样不需要头部和子导航。
layoutWeight(1) 使内容区占据底部栏之上的所有剩余空间。Scroll 组件允许内容超出屏幕高度时滚动浏览,这是长内容列表页面的标准布局方式。
18.3 顶层 build 组装
build() {
Stack() {
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
this.fxLayer()
if (this.addOpen) {
Column() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Column() {
this.modalOverlay()
Column() {
this.editModalBody()
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Column() {
this.modalOverlay()
Column() {
this.delModalBody()
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
}
if (this.challengeOpen) {
Column() {
this.modalOverlay()
Column() {
this.challengeModalBody()
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
build 方法是 ArkUI 组件的入口构建器,使用 Stack 将所有层级叠加。
最底层是 Column 包裹的内容区和底部栏(垂直排列)。mainContent() 占据上方可滚动区域,bottomBar() 固定在底部。这个 Column 使用 width('100%') 和 height('100%') 填满整个屏幕。
第二层是特效层 fxLayer(),覆盖在内容区上方但通过 HitTestMode.None 不拦截点击。金币飘落在内容之上,但不影响用户操作。
第三层及以上是四个弹框层,通过 if 条件渲染控制显隐。每个弹框层都是一个全屏的 Column,内含两个子元素:modalOverlay()(遮罩层)和对应的内容卡(如 addModalBody())。遮罩层先渲染(在底层),内容卡后渲染(在上层),justifyContent(FlexAlign.Center) 使内容卡在遮罩层中居中显示。
四个弹框层的渲染顺序遵循 if 条件的排列顺序:新增愿望、编辑目标、删除记录、发起挑战。但由于同时最多只有一个弹框打开(四个布尔状态互相独立但实际使用中不会同时为 true),所以渲染顺序不影响视觉。如果出现两个弹框同时打开的情况,后渲染的弹框会覆盖在前者之上。
整个 Stack 使用 COLORS.bg(#F4F7FB)作为背景色,这是应用的全局背景色。Stack 的子元素按声明顺序从底到顶叠加——先声明的在底层,后声明的在顶层。这种层级设计确保了:功能界面在最底层,特效在中间层(不拦截点击),弹框在最顶层(完全遮盖)。
十九、功能模块对比表
| 模块 | 核心职责 | 关键状态变量 | 数据来源 | 视觉特色 | 交互方式 | Builder 方法 |
|---|---|---|---|---|---|---|
| 特效层 | 金币飘落+存钱罐光晕 | tick | 纯函数计算 | Emoji+取模动画 | 不可交互(穿透) | fxLayer |
| 银行头部 | 总资产展示+快捷入口 | subTab | 静态常量 | 深蓝渐变大卡 | 点击跳转/打开弹框 | header |
| 子导航栏 | 首页内容Tab切换 | subTab | SUB_NAV_LIST | 胶囊高亮+金色圆点 | 点击切换 | subNav |
| 总览页 | 资产概览+榜单+日历+动态 | postList | 多源混合 | 三卡+榜单+日历网格 | 只读浏览 | pageOverview |
| 愿望页 | 攒钱目标进度管理 | wishList | WISH_LIST+动态增删 | 进度条+操作按钮 | 编辑/删除/新增 | pageWish |
| 账单页 | 收支图表+流水列表 | 无 | BILL_LIST+图表数据 | 双柱图+条形图+列表 | 点击删除 | pageBill |
| 挑战页 | 社区挑战参与 | postList | CHALLENGE_LIST | 渐变引导卡+天数方块 | 加入挑战 | pageChallenge |
| 笔记页 | 理财内容浏览 | 无 | NOTE_LIST | 彩色标签+文字列表 | 只读浏览 | pageNote |
| 个人页 | 用户信息+设置入口 | mainTab | 静态常量 | 头像卡+菜单+统计 | 点击回首页 | pageMine |
| 新增弹框 | 创建攒钱愿望 | addName/addTarget/addIcon | 用户输入 | 渐变头图+图标选择 | 表单提交 | addModalBody |
| 编辑弹框 | 修改愿望目标 | editName/editTarget/editDeadline | 回显+用户输入 | 分段进度指示 | 表单提交 | editModalBody |
| 删除弹框 | 确认删除操作 | delOpen | 无 | 蓝色警示居中卡 | 二选一确认 | delModalBody |
| 挑战弹框 | 发起社区挑战 | challengeName/challengeDays | 用户输入 | 排行榜虚位占位 | 表单提交 | challengeModalBody |
| 通用遮罩 | 弹框背景遮罩 | 四个布尔值 | 无 | 半透明深蓝灰 | 点击关闭全部 | modalOverlay |
| 底部导航 | 主Tab切换 | mainTab | NAV_LIST | 文字色变 | 点击切换 | bottomBar |
| 内容路由 | 页面组装与路由 | mainTab/subTab | 无 | 条件分支渲染 | 无 | mainContent |
弹框设计对比表
| 弹框 | 头部风格 | 主按钮颜色 | 宽度 | 最大高度 | 特殊设计 |
|---|---|---|---|---|---|
| 新增愿望 | 深蓝到橙渐变 | 蓝色 primary | 100% | 80% | 六图标选择器 |
| 编辑目标 | 无渐变+分段进度 | 蓝色 primary | 100% | 80% | 三步进度指示器 |
| 删除记录 | 无头部+居中图标 | 蓝色 primary | 86% | 无限制 | 警示文案+进度回退提醒 |
| 发起挑战 | 米黄背景+剑盾图标 | 橙色 accent | 100% | 80% | 虚位以待排行榜占位 |
色彩语义对比表
| 色彩 | RGB 值 | 语义含义 | 使用场景 |
|---|---|---|---|
| primary #1565C0 | (21,101,192) | 专业、信任 | 头部渐变终点、主按钮、选中态 |
| accent #FF8F00 | (255,143,0) | 活力、注意 | 进度条、挑战渐变、挑战按钮 |
| success #2E7D32 | (46,125,50) | 正向、增长 | 收益金额、已达成状态 |
| danger #D32F2F | (211,47,47) | 警示、支出 | 删除按钮、连续打卡标签 |
| gold #FBC02D | (251,192,45) | 攒钱、装饰 | 子导航圆点、金币主题 |
二十、总结与展望
本文深入剖析了一款基于 HarmonyOS ArkUI 框架的年轻人攒钱社区应用,从色彩体系、数据模型、工具函数到组件构建、弹框系统、特效层和最终组装,完整呈现了声明式 UI 开发的实践路径。以下从多个维度进行总结。
20.1 技术架构总结
该应用充分展现了 ArkUI 声明式范式的优势。通过 @State 管理状态、@Builder 拆分视图、@Observed 观察数据,整个应用在单一组件结构中实现了复杂的多页面路由和弹框管理。18 个 @State 变量覆盖了导航索引、弹框开关、表单数据、列表数据和动画计数器等全部 UI 状态,每个状态变化都由框架自动检测并触发精确的差量更新。
特效层的设计尤其值得借鉴——利用 setInterval 驱动 tick 计数器,配合八个纯函数计算坐标和透明度,以极低的代码量实现了持续的金币飘落动画和存钱罐呼吸光晕。同时通过 hitTestBehavior(HitTestMode.None) 确保动画不干扰功能交互,这是"游戏化视觉"与"功能可用性"平衡的优秀范例。这种"数据驱动动画"的模式是声明式框架下动画实现的典型实践——动画的每一帧都是状态的函数,状态变化触发框架重新渲染,视觉变化是状态映射的自然结果。
20.2 视觉设计总结
应用的色彩策略"蓝色铺底+橙金点睛"精准传达了"金融专业感+年轻活力感"的双重定位。16 种色彩通过 ColorPalette 接口强类型约束,每个色彩都有明确的语义含义和使用场景。银行风格头部的大号资产数字和深蓝渐变建立信任,金币特效和 Emoji 头像注入趣味。
四种弹框各具风格(渐变头图、分段进度、蓝色警示、排行榜卡)避免了弹框交互的单调感。子导航栏的"胶囊高亮+金色圆点"设计在选中态视觉上呼应了金币主题,是细节设计的亮点。整个应用的视觉层次从上到下形成"深蓝头部 → 浅灰蓝导航 → 白色卡片"的三级色彩递进,层次分明且不显杂乱。
20.3 工程实践总结
数据模型的 @Observed 标注和构造函数设计为接入真实后端做好了准备——五个数据模型类各有明确的字段定义和构造函数,从 JSON 数据创建实例只需一行代码。静态数据与动态状态的分离使数据替换成本极低——将 WISH_LIST 和 SAVE_POSTS 替换为 API 返回的数据,其余代码无需改动。
工具函数的纯函数设计保证了可测试性和可复用性。八个函数不依赖任何组件状态,接收参数返回计算结果,可以独立于组件进行单元测试。ForEach 键值函数的设计(如将 saved 纳入愿望列表的键值)体现了对 ArkUI 列表渲染机制的深入理解——键值的变化决定列表项是否重新渲染,将数据中变化的字段纳入键值是确保列表正确更新的关键。
20.4 交互设计总结
应用的交互设计遵循"高频操作前置、低频操作收纳"的原则。头部快捷入口栏将"存一笔"“记一笔”“新目标”"挑战"四个高频操作放在最显眼的位置,用户无需翻找底部导航即可触达。弹框系统采用"遮罩层+内容卡"的 Stack 叠加模式,点击遮罩关闭所有弹框,交互逻辑统一且符合用户直觉。
愿望列表的编辑和删除操作直接嵌入在卡片右侧,用户无需进入二级页面即可操作——这种"就地编辑"模式减少了页面跳转,提高了操作效率。挑战列表的加入按钮也直接在卡片上,点击后立即在社区动态中生成打卡记录,形成"操作→反馈"的闭环。
20.5 攒钱社区产品设计总结
从产品角度看,"掌上攒"应用精准定位了年轻人攒钱场景的核心需求:目标管理(愿望清单+进度条)、记账分析(账单流水+消费结构图表)、社交激励(打卡动态+攒钱挑战+排行榜)、知识获取(理财笔记)。这四个功能域通过底部导航和子导航组织为清晰的信息架构,用户可以在不同功能间快速切换。
游戏化元素的融入是该应用的亮点——金币特效、等级标签(尊享 Lv.6)、连续打卡天数、勋章标签(攒钱新星、打卡狂魔)、挑战排行榜等设计,将攒钱从枯燥的数字管理变为有成就感的游戏化体验。这种"游戏化+金融"的融合设计正是年轻人攒钱应用区别于传统银行 App 的核心竞争力。
20.6 技术展望与未来方向
展望未来,该应用可以在以下方向深化:
其一,将 setInterval 驱动的 tick 特效升级为 animateTo 显式动画 API。ArkUI 提供了 animateTo 和 animation 两个动画修饰器,可以实现更平滑的过渡效果和更精细的缓动控制。当前约 9 FPS 的跳跃式动画虽然形成了独特的像素艺术风格,但在高端设备上可以提供 60 FPS 的平滑动画选项,让用户自行选择动画质量。
其二,引入 @ObjectLink 实现跨组件的数据观察,将单组件拆分为真正的组件树。当前所有 UI 都在一个 @Entry @Component 内,通过 @Builder 拆分虽然是可行方案,但无法实现组件级的复用和独立更新。将愿望卡片、账单条目、挑战卡片等拆分为独立的 @Component,配合 @ObjectLink 观察数据变化,可以实现更精细的差量更新——只有变化的卡片重新渲染,而非整个列表重新构建。
其三,接入分布式数据管理,实现多设备间的攒钱数据同步。HarmonyOS 的分布式能力是该框架的独特优势,用户在手机上记录的账单可以自动同步到平板和智慧屏,实现"随时随地记账"的无缝体验。这需要将静态数据替换为分布式数据管理接口返回的 @Observed 数据。
其四,利用 ArkUI 的 @Watch 装饰器监听状态变化,实现攒钱达成目标时的自动庆祝动效。当 percentOf(w.saved, w.target) 从 99 变为 100 时,触发一个庆祝动画——金币爆发、烟花特效、成就弹框等,给用户即时的正向反馈,增强攒钱的成就感。
其五,将 Emoji 图标替换为自定义矢量图标或 Lottie 动画图标。Emoji 的优势是跨设备一致性和零包体积,但视觉精细度有限。在高端版本中,可以使用 SVG 矢量图标或 Lottie 动画替代 Emoji,实现更精细的视觉效果和品牌识别度。同时可以引入图标主题切换功能,让用户自定义应用图标风格。
在鸿蒙生态蓬勃发展的当下,ArkUI 声明式框架为开发者提供了高效、优雅的 UI 构建能力。本文剖析的应用虽以模拟数据驱动,但其架构分层、色彩体系、特效设计和弹框系统的实践,为同类金融理财类应用的开发提供了可复用的参考范式。随着 HarmonyOS 的持续演进和全场景多设备战略的推进,声明式 UI 的潜力将进一步释放——从手机到平板,从手表到智慧屏,从车机到 AR 眼镜,一套代码多端运行的能力将为开发者带来前所未有的效率提升。期待更多开发者在全场景多设备的舞台上,用代码书写更优雅的用户体验,用技术赋能更智慧的生活方式。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

1.4 完成创建
确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry |
标记为页面入口,可用于路由跳转 |
@Component |
声明为自定义组件 |
@State |
状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer |
相对布局容器,替代传统线性布局 |
.onClick() |
点击事件,此处点击后文本变为 “Welcome” |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK(或快捷键
Ctrl + Alt + S搜索 “HarmonyOS SDK”)
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐




所有评论(0)