首页布局与 SummaryCard 设计
本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第05篇,对应 Git Tagv0.0.5。承接第 04 篇的底导航,本篇开发首页核心 UI:今日支出、今日收入、最近账单列表,并封装 SummaryCard 汇总卡片组件。
前言
首页是用户进入应用后的第一印象,需要在3 秒内告诉用户:今天花了多少、挣了多少、最近账单如何。HarmonyLedger 首页采用卡片化布局,用 SummaryCard 展示收支汇总,用 List 展示最近账单。本篇暂时用 Mock 数据填充,后续 v0.0.7 篇接入真实 Repository。
本文将带你:
- 设计首页整体布局(汇总卡 + 最近账单 + FAB)
- 封装 SummaryCard 通用汇总卡片组件
- 使用 List + ListItem 渲染最近账单列表
- 集成主题系统实现深色模式
企业级核心原则:首页必须一目了然、层次清晰、操作直达。参考 ArkUI List 组件 了解官方约定。
一、首页布局规划
1.1 页面结构树
HomeView(首页) ├─ TitleBar(标题栏:今日账单汇总) ├─ SummaryCard(今日支出卡) ├─ SummaryCard(今日收入卡) ├─ SectionTitle(最近账单标题) └─ List(最近账单列表) └ ListItem(账单项)1.2 关键交互
| 区 | 操作 | 行为 |
|---|---|---|
| 支出汇总卡 | 点击 | 跳转今日支出详情 |
| 收入汇总卡 | 点击 | 跳转今日收入详情 |
| 账单项 | 点击 | 跳转账单详情 |
| 账单项 | 长按 | 弹出删除菜单 |
| FAB 按钮 | 点击 | 跳转新增账单页 |
二、SummaryCard 组件封装
2.1 组件设计目标
- 大数字突出:金额字号 36,加粗显示
- 语义色:收入绿、支出红、预算蓝
- 图标装饰:右上角小图标辅助识别
- 点击回调:通过
@BuilderParam暴露点击事件 - 响应深色模式:通过 AppStorage 集成主题
2.2 组件实现
// components/card/SummaryCard.ets import { AppColors } from '../../theme/Colors'; import { AppFontSize, AppFontWeight } from '../../theme/Typography'; import { AppSpace } from '../../theme/Spacing'; @Component export struct SummaryCard { @Prop title: string = ''; // "今日支出" @Prop money: string = '0.00'; // "123.45" @Prop icon: Resource = $r('app.media.icon_expense'); // 图标 @Prop semanticColor: string = AppColors.Expense; // 主题色 @BuilderParam onClick: () => void; build() { Column() { Row() { Column() { Text(this.title) .fontSize(AppFontSize.SM) .fontColor(AppColors.SecondaryText) Text(`¥${this.money}`) .fontSize(AppFontSize.Display) .fontColor(this.semanticColor) .fontWeight(AppFontWeight.Bold) .margin({ top: AppSpace.XS }) } .alignItems(HorizontalAlign.Start) Image(this.icon) .width(28) .height(28) .fillColor(this.semanticColor) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .alignItems(HorizontalAlign.Center) } .padding(AppSpace.CardPadding) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) .onClick(() => { this.onClick(); }) } }2.3 资源占用
| 资源 | 用途 |
|---|---|
icon_expense.png | 支出汇总图标 |
icon_income.png | 收入汇总图标 |
icon_budget.png | 预算汇总图标 |
三、首页布局实现
3.1 HomeView 完整代码
// pages/HomeView.ets import { SummaryCard } from '../components/card/SummaryCard'; import { SectionTitle } from '../components/text/SectionTitle'; import { BillCard } from '../components/bill/BillCard'; import { AppColors } from '../theme/Colors'; import { AppSpace } from '../theme/Spacing'; import { HomeViewModel } from '../viewmodel/HomeViewModel'; @Entry @Component struct HomeView { @State viewModel: HomeViewModel = new HomeViewModel(); aboutToAppear() { this.viewModel.loadMockData(); } build() { Scroll() { Column() { // 标题 Text('今日账单汇总') .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ top: AppSpace.XL, bottom: AppSpace.MD }) // 收支汇总卡 Row() { SummaryCard({ title: '今日支出', money: this.viewModel.todayExpense, icon: $r('app.media.icon_expense'), semanticColor: AppColors.Expense, onClick: () => { this.viewModel.goExpenseDetail(); } }) SummaryCard({ title: '今日收入', money: this.viewModel.todayIncome, icon: $r('app.media.icon_income'), semanticColor: AppColors.Income, onClick: () => { this.viewModel.goIncomeDetail(); } }) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) // 最近账单标题 SectionTitle({ title: '最近账单' }) .margin({ top: AppSpace.XL, bottom: AppSpace.SM }) // 最近账单列表 List({ space: AppSpace.SM }) { ForEach(this.viewModel.recentBills, (bill: Bill, index: number) => { ListItem() { BillCard({ money: bill.money, type: bill.type, category: bill.category, remark: bill.remark }) } }, (bill: Bill) => bill.id) } .width('100%') .layoutWeight(1) .padding({ bottom: 80 }) } .width('100%') .padding({ left: AppSpace.XL, right: AppSpace.XL }) } .width('100%') .height('100%') .scrollBar(BarState.Off) } }3.2 FAB 快捷按钮
// 在 HomeView build 内追加 FAB Stack() { Scroll() { ... } // 上述内容 Image($r('app.media.icon_add')) .width(56) .height(56) .fillColor(AppColors.White) .backgroundColor(AppColors.Budget) .borderRadius(28) .shadow({ radius: 8, color: '#66000000', offsetY: 4 }) .position({ x: '85%', y: '85%'' }) .onClick(() => { this.viewModel.goAddBill(); }) }四、HomeViewModel 雏创
4.1 ViewModel 设计
企业级 MVVM 要求页面只管 UI,业务逻辑放 ViewModel:
// viewmodel/HomeViewModel.ets import { Bill } from '../model/Bill'; import { MoneyUtil } from '../utils/MoneyUtil'; export class HomeViewModel { todayExpense: string = '0.00'; todayIncome: string = '0.00'; recentBills: Bill[] = []; /** 加载今日数据(本篇用 Mock) */ loadMockData(): void { const mockBills: Bill[] = [ new Bill('1', 3500, 'expense', 'food', '午餐', Date.now()), new Bill('2', 8000, 'income', 'salary', '今日工资', Date.now()), new Bill('3', 2500, 'expense', 'transport', '打车', Date.now()) ]; this.recentBills = mockBills; const expenseSum = mockBills.filter(b => b.type === 'expense').sum(b => b.money); const incomeSum = mockBills.filter(b => b.type === 'income').sum(b => b.money); this.todayExpense = MoneyUtil.format(expenseSum); this.todayIncome = MoneyUtil.format(incomeSum); } goExpenseDetail(): void { /* RouterUtil.go('/expense/today'); */ } goIncomeDetail(): void { /* RouterUtil.go('/income/today'); */ } goAddBill(): void { /* RouterUtil.go('/bill/add'); */ } }4.2 Mock 数据 Bill
// model/Bill.ets(占位版) export class Bill { id: string; money: number; // 分 type: string; // 'expense' / 'income' category: string; // 分类名 remark: string; date: number; constructor(id: string, money: number, type: string, category: string, remark: string, date: number) { this.id = id; this.money = money; this.type = type; this.category = category; this.remark = remark; this.date = date; } }4.3 MoneyUtil 工具类
// utils/MoneyUtil.ets export class MoneyUtil { /** 分 → 元字符串 */ static format(cents: number): string { return (cents / 100).toFixed(2); } /** 元 → 分 */ static toCents(yuan: number): number { return Math.round(yuan * 100); } }设计要点:金额统一以分为单位存储,避免浮点精度问题。这是金融类应用的基本规范。
五、SectionTitle 与 BillCard 占位
5.1 SectionTitle
// components/text/SectionTitle.ets @Component export struct SectionTitle { @Prop title: string = ''; build() { Text(this.title) .fontSize(18) .fontWeight(FontWeight.Bold) } }5.2 BillCard 占位
// components/bill/BillCard.ets(占位,v0.0.6 篇完善) import { AppColors } from '../../theme/Colors'; import { AppFontSize } from '../../theme/Typography'; import { AppSpace } from '../../theme/Spacing'; import { MoneyUtil } from '../../utils/MoneyUtil'; @Component export struct BillCard { @Prop money: number = 0; @Prop type: string = 'expense'; @Prop category: string = ''; @Prop remark: string = ''; build() { Row() { Column() { Text(this.category) .fontSize(AppFontSize.MD) .fontColor(AppColors.PrimaryText) Text(this.remark) .fontSize(AppFontSize.SM) .fontColor(AppColors.SecondaryText) .margin({ top: 2 }) } .alignItems(HorizontalAlign.Start) Text(`¥${MoneyUtil.format(this.money)}`) .fontSize(AppFontSize.LG) .fontColor(this.type === 'income' ? AppColors.Income : AppColors.Expense) .fontWeight(FontWeight.Bold) } .width('100%') .padding(AppSpace.CardPadding) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) .justifyContent(FlexAlign.SpaceBetween) .alignItems(HorizontalAlign.Center) } }六、最佳实践
6.1 MVVM 分层
View (HomeView) ↓ 持有 ViewModel (HomeViewModel) ↓ 调用 Repository (BillRepository) ← v0.0.7 接入 ↓ 操作 Model (Bill)为什么必须分层?
- View 只管渲染,不写业务逻辑
- ViewModel 可单元测试
- Repository 屏蔽存储实现
- Model 集中数据定义
6.2 Mock 数据策略
// 开发期用 Mock,上线期换真实 Repository loadData(): void { if (DEBUG) { this.loadMockData(); // Mock 数据 } else { BillRepository.getTodayBills() // 真实数据 .then(bills => { ... }); } }6.3 List 性能
| 配置 | 说明 |
|---|---|
List({ space: 8 }) | Item 间距 8dp |
.layoutWeight(1) | 占满剩余空间 |
ForEachkey | 必须用 bill.id,禁止用 index |
踩坑点:
ForEachkey 用 index 会导致列表项状态错乱,必须用业务 id。
七、运行验证
7.1 编译检查
hvigorw assembleHap--modemodule-pproduct=default7.2 视觉验证
- 首页顶部显示"今日账单汇总"标题
- 支出卡与收入卡并排,金额用对应语义色
- 最近账单列表显示 3 条 Mock 数据
- FAB 按钮浮在右下角,蓝色圆形
- 深色模式自动切换卡片背景色
八、常见问题
8.1 Scroll 嵌套 List 冲突
// 错误:Scroll 嵌套 List 会冲突 Scroll() { Column() { List() { ... } // ❌ List 无法滚动 } } // 解决:用 Column + List.layoutWeight(1) 替代 Column() { Column() { ... } // 顶部固定内容 List() { ... }.layoutWeight(1) // 自动占满剩余 }8.2 ForEach key 缺失
// 错误:ForEach 缺 key,列表项刷新错乱 ForEach(this.bills, (bill) => { ListItem() { ... } }) // 正解:必须提供稳定 key ForEach(this.bills, (bill) => { ... }, (bill) => bill.id)九、Git 提交
9.1 Commit Message
gitadd.gitcommit-m"feat(home): 完成首页布局与 SummaryCard 设计 - 新增 SummaryCard 通用汇总卡片组件 - 新增 SectionTitle、BillCard 占位组件 - 集成 HomeView 完整布局:标题 + 汇总卡 + 列表 + FAB - 创建 HomeViewModel 处理业务逻辑 - 创建 Mock Bill 数据模型与 MoneyUtil 工具类"9.2 CHANGELOG
## [v0.0.5] - 2026-07-27 ### Added - components/card/SummaryCard.ets:汇总卡片 - components/text/SectionTitle.ets:区块标题 - components/bill/BillCard.ets:账单卡片占位 - pages/HomeView.ets:完整首页布局 - viewmodel/HomeViewModel.ets:首页业务逻辑 - model/Bill.ets:账单数据模型(占位版) - utils/MoneyUtil.ets:金额工具类总结
本文完整介绍了首页布局与 SummaryCard 组件,包含页面结构、组件封装、ViewModel 抽离、Mock 数据策略。通过本篇你可以:
- 设计首页一目了然的卡片化布局
- 封装 SummaryCard 通用汇总组件
- 使用 List + ForEach 渲染账单列表
- 抽离 HomeViewModel 落地 MVVM 架构
- 处理 Scroll 与 List 嵌套冲突
下一篇预告:《封装 BillCard 通用组件》将完善 BillCard 组件,支持分类图标、金额语义色、长按删除、滑动删除等交互。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源
- 本篇源码:GitHub Tag v0.0.5
- ArkUI List 组件:list
- ArkUI Scroll 组件:scroll
- ForEach 性能指南:foreach-performance
- MVVM 架构实践:mvvm-practice
- 鸿蒙状态管理:state-management
- ArkUI Stack 布局:stack