数据仪表盘:ArkUI 让鸿蒙健康页像运动手环


实例:健康数据(Health)|风格:数据仪表盘
一、设计理念:健康页要「一眼看懂」
健康数据的 UI 目标是运动手环/健康 App——一屏看懂「今天状态 + 本周趋势」。实例 14 的风格「数据仪表盘」:概览卡片(大数字)+ 趋势柱状图(柱条)+ 明细列表,信息密度高但层级分明。
页面信息架构:
- 标题栏:健康数据 + 标语 + 刷新;
- 今日概览卡:体重/步数/睡眠/心率四指标大数字——「今天」的仪表盘;
- 本周趋势卡:体重周变化(↑↓箭头)+ 近 7 天睡眠柱状图(Text 柱条模拟);
- 本周汇总卡:步数日均/饮水日均/运动日均——周汇总数字;
- 记录列表:近 7 天健康记录明细(日期 + 六指标);
- 记录表单:新增/编辑当日记录(六指标输入)。
二、页面骨架与状态定义
@State records: HealthRecord[] = [];
@State todayRecord: HealthRecord | null = null;
@State weekAvgWeight: number = 0;
@State weekAvgSteps: number = 0;
@State weekAvgSleep: number = 0;
@State weekAvgWater: number = 0;
@State weekAvgExercise: number = 0;
@State lastWeekWeight: number = 0; // 上周体重均值(趋势对比)
@State formVisible: boolean = false;
@State editing: HealthRecord | null = null;
@State fWeight: string = '';
@State fSteps: string = '';
@State fSleep: string = '';
@State fHeart: string = '';
@State fWater: string = '';
@State fExercise: string = '';
状态组与数据组对应:records(列表)、todayRecord(今日概览)、weekAvg*(周汇总)、lastWeekWeight(趋势对比)——页面数据流 = 今日 + 本周 + 上周三类查询结果。
三、今日概览卡:四指标大数字
顶部「今日健康」卡片,四格大数字:
Row({ space: 8 }) {
MetricPanel('体重', `${this.todayRecord?.weight ?? '-'}kg`)
MetricPanel('步数', `${this.todayRecord?.steps ?? '-'}`)
MetricPanel('睡眠', `${this.todayRecord?.sleepHours ?? '-'}h`)
MetricPanel('心率', `${this.todayRecord?.heartRate ?? '-'}bpm`)
}.width('94%').margin({ top: 10 })
MetricPanel 抽成 @Builder(本实例重要的 UI 复用):
@Builder
MetricPanel(label: string, value: string) {
Column() {
Text(value).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#0EA5E9')
Text(label).fontSize(11).fontColor('#6B7280').margin({ top: 4 })
}
.layoutWeight(1).padding({ top: 14, bottom: 14 })
.backgroundColor(Color.White).borderRadius(10)
}
@Builder 复用:四个指标卡用同一个 Builder 渲染(label + value),值用数据驱动——「体重 73.5kg / 步数 8321 / 睡眠 7.5h / 心率 65bpm」。无今日记录时显示 ‘-’(null 兜底)。
四、本周趋势卡:体重变化 + 睡眠柱状图
本周趋势卡包含两个可视化:
1. 体重周变化(对比上周):
Row() {
Text('体重趋势').fontSize(14).fontWeight(FontWeight.Bold)
Text(this.trendText).fontSize(12).fontColor(this.trendColor).layoutWeight(1).textAlign(TextAlign.End)
}.width('100%')
trendText 与 trendColor 的计算:
private trendText(): string {
const diff = this.weekAvgWeight - this.lastWeekWeight;
if (this.weekAvgWeight === 0 || this.lastWeekWeight === 0) {
return '数据不足';
}
return diff > 0 ? `↑ 较上周 +${diff.toFixed(1)}kg` : `↓ 较上周 ${diff.toFixed(1)}kg`;
}
趋势语义:本周均值 vs 上周均值——正值(涨)红色、负值(跌)绿色(减重是健康目标)。趋势 = 对比查询:两组均值相减。
2. 近 7 天睡眠柱状图(Text 柱条模拟):
Row({ space: 4 }) {
ForEach(this.sleepBars, (bar: SleepBar) => {
Column() {
Column()
.width(18)
.height(Math.max(bar.value * 8, 2)) // 高度按数值缩放
.borderRadius(4)
.backgroundColor(this.isToday(bar.date) ? '#0EA5E9' : '#BAE6FD')
Text(bar.date.substring(8, 10)).fontSize(9).fontColor('#9CA3AF').margin({ top: 4 })
}
}, (bar: SleepBar) => bar.date)
}
柱状图的零依赖实现:每根柱 = 一个 Column().height(value * 8)——高度按睡眠时长缩放(7.5h → 60 高度),今天高亮(深蓝)。用布局高度画柱状图——不引入图表库,纯 ArkUI 组件拼出趋势视觉。SleepBar 接口:{ date: string; value: number }。
五、本周汇总卡:五指标日均
本周汇总(日均)数字排:
Row({ space: 8 }) {
Column() { Text(String(this.weekAvgSteps)).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#F59E0B'); Text('日均步数').fontSize(10).fontColor('#6B7280') }.layoutWeight(1)
Column() { Text(`${this.weekAvgWater}ml`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#0EA5E9'); Text('日均饮水').fontSize(10).fontColor('#6B7280') }.layoutWeight(1)
Column() { Text(`${this.weekAvgExercise}min`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#059669'); Text('日均运动').fontSize(10).fontColor('#6B7280') }.layoutWeight(1)
}
.padding(14).backgroundColor(Color.White).borderRadius(12).width('94%').margin({ top: 10 })
周汇总数字:日均步数/饮水/运动——由 DAO 的周 AVG 查询供给(14-3 详解)。一行三格的紧凑汇总,颜色区分指标。
六、记录列表与记录表单
记录列表(近 7 天明细):
List({ space: 8 }) {
ForEach(this.records, (r: HealthRecord) => {
ListItem() {
Row() {
Column() {
Text(r.recordDate).fontSize(13).fontWeight(FontWeight.Bold)
Text(r.remark || '—').fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(`体重 ${r.weight}kg`).fontSize(12)
Text(`步数 ${r.steps}`).fontSize(12).margin({ left: 10 })
Text(`睡眠 ${r.sleepHours}h`).fontSize(12).margin({ left: 10 })
}.width('100%').padding(12).backgroundColor(Color.White).borderRadius(10)
.onClick(() => this.openEdit(r))
}
}, (r: HealthRecord) => r.recordDate)
}
.width('94%').layoutWeight(1).margin({ top: 10 }).scrollBar(BarState.Off)
每行「日期 + 备注 + 体重/步数/睡眠」——点行编辑。
记录表单(新增/编辑六指标):
if (this.formVisible) {
Column() {
Text(this.editing ? '编辑记录' : '新增记录').fontSize(18).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '体重 kg', text: this.fWeight }).margin({ top: 10 }).type(InputType.Number).onChange((v: string) => this.fWeight = v)
TextInput({ placeholder: '步数', text: this.fSteps }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fSteps = v)
TextInput({ placeholder: '睡眠时长 h', text: this.fSleep }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fSleep = v)
TextInput({ placeholder: '静息心率 bpm', text: this.fHeart }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fHeart = v)
TextInput({ placeholder: '饮水 ml', text: this.fWater }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fWater = v)
TextInput({ placeholder: '运动时长 min', text: this.fExercise }).margin({ top: 6 }).type(InputType.Number).onChange((v: string) => this.fExercise = v)
Row({ space: 8 }) {
Button('取消').layoutWeight(1).backgroundColor('#EEF2F7').fontColor('#555555')
.onClick(() => this.formVisible = false)
Button('保存').layoutWeight(1).backgroundColor('#0EA5E9')
.onClick(() => this.onSave())
}.margin({ top: 16 })
}
.padding(20).borderRadius(16).backgroundColor(Color.White).width('90%')
.position({ x: '5%', y: '8%' })
}
InputType.Number:数字键盘——六指标都是数值输入。保存逻辑(新增与编辑分支):
async onSave(): Promise<void> {
const date = this.editing ? this.editing.recordDate : HealthDao.today();
const record: HealthRecord = {
id: this.editing ? this.editing.id : 0,
recordDate: date,
weight: parseFloat(this.fWeight) || 0,
steps: parseInt(this.fSteps) || 0,
sleepHours: parseFloat(this.fSleep) || 0,
heartRate: parseInt(this.fHeart) || 0,
water: parseInt(this.fWater) || 0,
exerciseMinutes: parseInt(this.fExercise) || 0,
remark: '',
};
try {
if (this.editing) {
await HealthDao.update(this.context, record);
} else {
await HealthDao.insert(this.context, record);
}
this.formVisible = false;
await this.refresh();
promptAction.showToast({ message: '💾 健康记录已保存' });
} catch (e) {
promptAction.showToast({ message: `保存失败: ${e}` });
}
}
parseFloat/parseInt + || 0:字符串转数值,非法输入兜底 0。同日重复新增:insert 时 DAO 检查 UNIQUE 冲突(14-3 详解)——页面提示「今天已有记录,请编辑」。
七、UI 风格要素一览
| 风格项 | 取值 | 说明 |
|---|---|---|
| 页面背景 | #F0F5FA 极浅蓝 |
健康清爽 |
| 主色 | #0EA5E9 天蓝 |
健康指标 |
| 概览卡 | 白底四格大数字 | 仪表盘 |
| 柱状图 | Text 柱条 + 高度缩放 | 零依赖趋势 |
| 趋势箭头 | ↑ 红 / ↓ 绿 | 体重对比 |
| 数字输入 | InputType.Number | 数值键盘 |
八、文章小结
本篇文章完成了实例 14 的 UI 层:今日概览卡(四指标大数字 + @Builder 复用)+ 本周趋势卡(体重对比箭头 + 柱状图)+ 周汇总卡 + 记录列表 + 六指标表单。设计核心是「仪表盘感」——大数字概览 + 柱状趋势一眼看懂;技术亮点是零依赖柱状图(Column 高度按值缩放)与 @Builder 抽复用(MetricPanel 四卡同构)。健康页像运动手环,数据可视化全靠 ArkUI 基础组件。
下一篇(14-3)深入数据层,讲解周汇总聚合查询与趋势对比——AVG/SUM + 日期范围是本实例的技术核心。
九、补充:仪表盘布局细节
1. MetricPanel 复用再深入:为什么是 @Builder 而不是自定义组件
四个指标卡(体重/步数/睡眠/心率)结构完全相同——「值 + 标签」上下排列。用 @Builder 抽出来后,build() 里四行调用即可;若改用 @Component 自定义组件,还需额外声明 @Prop 传参,样板代码更多。@Builder 适合「轻量、无状态」的 UI 片段——MetricPanel 恰好如此:只接收 label/value 两个入参、没有内部状态,复用成本最低。
| 方案 | 适用场景 | 本项目选择 |
|---|---|---|
| @Builder 方法 | 无状态的 UI 片段、多处复用 | ✅ MetricPanel |
| @Component 组件 | 有状态、需独立生命周期的单元 | ❌ 概览卡无此需求 |
2. 柱状图高度缩放的边界处理
Math.max(bar.value * 8, 2) 的两个数字各有讲究:8 是缩放系数——睡眠 7.5h → 高度 60,柱条在卡片内比例协调;2 是下限兜底——某天睡眠为 0(未记录)时,0 * 8 = 0 会让柱条不可见,视觉上像「缺了一天」,用 2 保证柱条始终可辨。若数值异常(如 24h),高度 192 也仍在卡片内,无需再设上限。
3. 表单预填逻辑:编辑与新增共用一套状态
fWeight 等六个 @State 字符串同时服务「新增」与「编辑」——openEdit(r) 时把记录的六项数值转成字符串填入输入框,新增时则清空。核心代码:
openEdit(r: HealthRecord) {
this.editing = r;
this.fWeight = String(r.weight);
this.fSteps = String(r.steps);
this.fSleep = String(r.sleepHours);
this.fHeart = String(r.heartRate);
this.fWater = String(r.water);
this.fExercise = String(r.exerciseMinutes);
this.formVisible = true;
}
预填的意义:编辑时用户看到的是当前值而非空输入框,改一个指标即可保存——表单的「可感知性」是编辑体验的关键。而 onSave 里 parseFloat(this.fWeight) || 0 与预填的 String 转换正好构成「数字 ↔ 字符串」的双向转换闭环。
4. FAQ
Q1:四个指标卡直接写四个 Column 不行吗?为什么要 @Builder?
可以,但四个 Column 会重复约 12 行结构代码;抽成 @Builder 后 build() 只剩四行调用,且日后想加角标、改配色只需改一处。复用的收益在「变更成本」——结构越相似,越值得抽。
Q2:柱状图为什么用 height 缩放而不是 Canvas 绘制?
Canvas 需要自己管理坐标、刻度、标签,代码量是 Text 柱条的 3 倍以上;而本页柱状图只有 7 根柱、无坐标轴需求,Column().height(value * 8) 一行即完成「数值 → 高度」映射。零依赖方案的取舍:功能够用、代码最少,复杂图表再上 Canvas 或图表库。
更多推荐




所有评论(0)