HarmonyOS 时间序列数据可视化实现
本文基于HarmonyOS开发了一个宝宝成长记录时间轴应用,支持生理指标和成长日记两种记录类型。系统采用分层架构设计,使用List组件实现时间轴布局,通过日期分组算法自动排序,并利用装饰器实现数据响应式更新。文章详细介绍了数据模型设计、日期工具类实现、UI组件构建等关键技术点,提供了完整的代码示例。同时提出了样式优化、响应式适配方案,以及数据持久化等扩展功能建议。该方案具有清晰的时序展现、良好的性
·
在育儿类应用中,时间轴是记录宝宝成长轨迹的核心功能。通过时间轴可以直观展示身高、体重等生理指标变化,以及成长过程中的重要事件。本文基于 HarmonyOS 的 List 组件,详细解析如何构建一个支持多类型记录、自动分组排序的成长记录时间轴,包含完整的实现思路、代码示例和优化策略。
一、功能场景与核心需求
宝宝成长记录时间轴需满足以下核心场景:
- 按日期分组展示成长数据,支持身高体重记录和图文日记两种形式
- 新记录默认按时间倒序排列,最新数据置顶显示
- 时间轴视觉上需体现时间流逝感,通过连接线和节点强化时序关系
- 支持新增、查看、编辑记录,数据变化时自动更新时间轴
1.1 数据类型设计
记录类型 核心字段 展示形式 生理指标 日期、身高、体重、记录人 数值卡片 + 趋势提示 成长日记 日期、标题、内容、图片列表、记录人 图文混排卡片 二、实现流程与关键技术
2.1 数据处理流程图
2.2 核心技术点
- 日期分组与排序:使用
Set
去重日期,按时间戳倒序排列 - 时间轴视觉实现:通过
ListItemGroup
头部展示日期节点,ListItem
左侧边框作为连接线 - 数据响应式更新:使用
@State
和@Observed
装饰器,数据变化时自动刷新 UI
三、完整代码实现
3.1 数据模型定义
// 数据模型:成长记录基类
export class GrowthRecord {
id: string; // 唯一标识
date: Date; // 记录日期
recorder: string; // 记录人
type: 'physical' | 'diary'; // 记录类型
constructor(recorder: string, date: Date = new Date()) {
this.id = `${date.getTime()}-${Math.random().toString(36).substr(2, 5)}`;
this.date = date;
this.recorder = recorder;
this.type = 'physical';
}
}
// 生理指标记录
export class PhysicalRecord extends GrowthRecord {
height: number; // 身高(cm)
weight: number; // 体重(kg)
constructor(recorder: string, height: number, weight: number, date: Date = new Date()) {
super(recorder, date);
this.type = 'physical';
this.height = height;
this.weight = weight;
}
}
// 成长日记记录
export class DiaryRecord extends GrowthRecord {
title: string;
content: string;
images: string[]; // 图片路径数组
constructor(recorder: string, title: string, content: string, images: string[] = [], date: Date = new Date()) {
super(recorder, date);
this.type = 'diary';
this.title = title;
this.content = content;
this.images = images;
}
}
// 分组数据结构
export interface GroupedRecords {
dateStr: string; // 格式化日期(yyyy-MM-dd)
records: GrowthRecord[]; // 该日期下的所有记录
}
3.2 日期工具类
// 日期格式化与处理工具
export class DateUtil {
// 格式化日期为yyyy-MM-dd
static formatDate(date: Date): string {
const year = date.getFullYear();
const month = this.padZero(date.getMonth() + 1);
const day = this.padZero(date.getDate());
return `${year}-${month}-${day}`;
}
// 数字补零
static padZero(num: number): string {
return num < 10 ? `0${num}` : `${num}`;
}
// 按日期分组并排序
static groupRecordsByDate(records: GrowthRecord[]): GroupedRecords[] {
// 1. 格式化所有日期并去重
const dateSet = new Set<string>();
records.forEach(record => {
const dateStr = this.formatDate(record.date);
dateSet.add(dateStr);
});
// 2. 日期倒序排序(最新在前)
const sortedDates = Array.from(dateSet).sort((a, b) => {
return new Date(b).getTime() - new Date(a).getTime();
});
// 3. 按日期分组
return sortedDates.map(dateStr => {
return {
dateStr,
records: records.filter(record => this.formatDate(record.date) === dateStr)
};
});
}
}
3.3 时间轴主组件
import { GrowthRecord, PhysicalRecord, DiaryRecord, GroupedRecords } from '../model/GrowthRecord';
import { DateUtil } from '../utils/DateUtil';
import { CommonConstants } from '../constants/CommonConstants';
@Observed
class TimeLineViewModel {
@Trace records: GrowthRecord[] = [];
// 添加新记录
addRecord(record: GrowthRecord) {
this.records.unshift(record); // 最新记录插入头部
}
// 获取分组数据
getGroupedRecords(): GroupedRecords[] {
return DateUtil.groupRecordsByDate(this.records);
}
}
@Entry
@Component
struct BabyGrowthTimeLine {
@State viewModel: TimeLineViewModel = new TimeLineViewModel();
@State isAddDialogVisible: boolean = false;
// 初始化示例数据
aboutToAppear() {
this.initSampleData();
}
// 示例数据
private initSampleData() {
// 添加生理指标记录
this.viewModel.addRecord(new PhysicalRecord('妈妈', 50, 3.5, new Date(2024, 3, 8)));
this.viewModel.addRecord(new PhysicalRecord('爸爸', 48, 3.2, new Date(2024, 3, 6)));
// 添加日记记录
this.viewModel.addRecord(new DiaryRecord(
'妈妈',
'第一次翻身',
'今天宝宝成功从仰卧翻成俯卧,好棒!',
[],
new Date(2024, 3, 8)
));
}
// 时间轴头部(日期节点)
@Builder
private timeLineHeader(dateStr: string) {
Row() {
// 时间节点
Column() {
Text(dateStr)
.fontSize(14)
.fontColor('#666')
// 圆形节点
Circle()
.width(12)
.height(12)
.fill('#ff6b6b')
}
.padding({ left: 15, right: 15 })
// 日期分隔线
Line()
.width('100%')
.height(1)
.stroke('#eee')
}
.alignItems(FlexAlign.Center)
}
// 渲染生理指标记录
@Builder
private renderPhysicalRecord(record: PhysicalRecord) {
Column() {
Text('生理指标记录')
.fontSize(14)
.fontColor('#ff6b6b')
.alignSelf(FlexAlign.Start)
Row({ space: 20 }) {
Column() {
Text('身高')
.fontSize(12)
.fontColor('#999')
Text(`${record.height}cm`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
}
Column() {
Text('体重')
.fontSize(12)
.fontColor('#999')
Text(`${record.weight}kg`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
}
}
.margin({ top: 10 })
Text(`记录人: ${record.recorder}`)
.fontSize(12)
.fontColor('#999')
.alignSelf(FlexAlign.End)
.margin({ top: 10 })
}
.padding(15)
.backgroundColor('#fff')
.borderRadius(8)
.shadow({ radius: 2, color: '#00000010' })
}
// 渲染日记记录
@Builder
private renderDiaryRecord(record: DiaryRecord) {
Column() {
Text(record.title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.alignSelf(FlexAlign.Start)
Text(record.content)
.fontSize(14)
.margin({ top: 5 })
.textAlign(TextAlign.Start)
if (record.images.length > 0) {
Row({ space: 5 })
.margin({ top: 10 })
.wrap(true)
.forEach(record.images, (imgPath) => {
Image(imgPath)
.width(80)
.height(80)
.borderRadius(4)
})
}
Text(`记录人: ${record.recorder}`)
.fontSize(12)
.fontColor('#999')
.alignSelf(FlexAlign.End)
.margin({ top: 10 })
}
.padding(15)
.backgroundColor('#fff')
.borderRadius(8)
.shadow({ radius: 2, color: '#00000010' })
}
build() {
Column() {
// 标题栏
Row() {
Text('宝宝成长记录')
.fontSize(20)
.fontWeight(FontWeight.Bold)
Button('+ 新增记录')
.type(ButtonType.Capsule)
.backgroundColor('#ff6b6b')
.onClick(() => {
this.isAddDialogVisible = true;
})
}
.padding(15)
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
// 时间轴列表
List() {
ForEach(this.viewModel.getGroupedRecords(), (group: GroupedRecords) => {
ListItemGroup({
header: this.timeLineHeader(group.dateStr)
}) {
ForEach(group.records, (record: GrowthRecord) => {
ListItem() {
// 根据记录类型渲染不同卡片
if (record.type === 'physical') {
this.renderPhysicalRecord(record as PhysicalRecord);
} else {
this.renderDiaryRecord(record as DiaryRecord);
}
}
// 左侧边框作为时间轴连接线
.border({
left: { width: 1, color: '#eee' }
})
.padding({ left: 30, top: 10, bottom: 10 })
})
}
})
}
.width('100%')
.padding({ left: 10 })
.edgeEffect(EdgeEffect.None) // 去除滚动边缘效果
}
.width('100%')
.height('100%')
.backgroundColor('#f5f5f5')
}
}
3.4 新增记录弹窗组件
// 新增记录弹窗
@CustomDialog
struct AddRecordDialog {
@Link isVisible: boolean;
@State recordType: 'physical' | 'diary' = 'physical';
@State height: string = '';
@State weight: string = '';
@State title: string = '';
@State content: string = '';
private onConfirm: (record: GrowthRecord) => void;
constructor(builder: { onConfirm: (record: GrowthRecord) => void, isVisible: Link<boolean> }) {
this.onConfirm = builder.onConfirm;
this.isVisible = builder.isVisible;
}
build() {
Column({ space: 15 }) {
Text('新增成长记录')
.fontSize(18)
.fontWeight(FontWeight.Bold)
// 记录类型选择
Row({ space: 20 }) {
Button('生理指标')
.type(this.recordType === 'physical' ? ButtonType.Capsule : ButtonType.Normal)
.backgroundColor(this.recordType === 'physical' ? '#ff6b6b' : '#fff')
.fontColor(this.recordType === 'physical' ? '#fff' : '#000')
.onClick(() => this.recordType = 'physical')
Button('成长日记')
.type(this.recordType === 'diary' ? ButtonType.Capsule : ButtonType.Normal)
.backgroundColor(this.recordType === 'diary' ? '#ff6b6b' : '#fff')
.fontColor(this.recordType === 'diary' ? '#fff' : '#000')
.onClick(() => this.recordType = 'diary')
}
// 生理指标表单
if (this.recordType === 'physical') {
Column({ space: 10 }) {
TextInput({ placeholder: '请输入身高(cm)' })
.keyboardType(InputType.Number)
.onChange((val) => this.height = val)
TextInput({ placeholder: '请输入体重(kg)' })
.keyboardType(InputType.Number)
.onChange((val) => this.weight = val)
}
} else {
// 日记表单
Column({ space: 10 }) {
TextInput({ placeholder: '请输入标题' })
.onChange((val) => this.title = val)
TextArea({ placeholder: '请输入日记内容' })
.height(100)
.onChange((val) => this.content = val)
}
}
// 确认/取消按钮
Row({ space: 20 }) {
Button('取消')
.width(120)
.onClick(() => this.isVisible = false)
Button('确认')
.width(120)
.backgroundColor('#ff6b6b')
.onClick(() => {
if (this.recordType === 'physical' && this.height && this.weight) {
this.onConfirm(new PhysicalRecord(
'妈妈', // 实际应用中应从登录信息获取
parseFloat(this.height),
parseFloat(this.weight)
));
this.isVisible = false;
} else if (this.recordType === 'diary' && this.title && this.content) {
this.onConfirm(new DiaryRecord(
'妈妈',
this.title,
this.content
));
this.isVisible = false;
}
})
}
.margin({ top: 10 })
}
.padding(20)
.width('90%')
}
}
四、样式设计与视觉优化
4.1 时间轴样式对比
样式元素 | 实现方案 | 效果 |
---|---|---|
日期节点 | 圆形 + 日期文本,使用ListItemGroup 头部 |
强化时间节点感知 |
连接线 | ListItem 左侧边框,颜色 #eee |
体现时序连续性 |
记录卡片 | 白色背景 + 圆角 + 轻微阴影 | 区分内容区域,提升层次感 |
排序方式 | 日期倒序排列,最新记录顶部 | 符合用户查看习惯 |
4.2 响应式适配
为确保在不同设备上的显示效果,需进行响应式设计:
// 响应式布局调整
private getCardWidth(): Length {
const deviceType = this.getDeviceType();
if (deviceType === 'phone') {
return '100%';
} else if (deviceType === 'tablet') {
return '80%';
} else {
return '60%';
}
}
// 判断设备类型
private getDeviceType(): 'phone' | 'tablet' | 'other' {
const width = this.context.display.getDisplaySync().width;
return width < 600 ? 'phone' : width < 1000 ? 'tablet' : 'other';
}
五、数据持久化与扩展功能
5.1 数据持久化实现
使用Preferences
保存记录,确保应用重启后数据不丢失:
import preferences from '@ohos.data.preferences';
export class RecordStorage {
private static PREF_NAME = 'growth_records';
// 保存记录
static async saveRecords(records: GrowthRecord[]) {
const pref = await preferences.getPreferences(getContext(), this.PREF_NAME);
await pref.put('records', JSON.stringify(records));
await pref.flush();
}
// 加载记录
static async loadRecords(): Promise<GrowthRecord[]> {
const pref = await preferences.getPreferences(getContext(), this.PREF_NAME);
const jsonStr = pref.get('records', '[]') as string;
const rawData = JSON.parse(jsonStr);
// 转换为具体记录类型
return rawData.map((item: any) => {
if (item.type === 'physical') {
return Object.assign(new PhysicalRecord('', 0, 0), item);
} else {
return Object.assign(new DiaryRecord('', '', ''), item);
}
});
}
}
5.2 扩展功能建议
- 数据统计:添加身高体重趋势图表,使用
Chart
组件可视化成长曲线 - 记录搜索:按日期、关键词筛选记录,提升数据检索效率
- 多媒体支持:日记记录支持拍照 / 相册选择图片,丰富记录形式
- 备份与同步:支持将记录同步到云端,避免数据丢失
六、约束与兼容性处理
约束项 | 具体要求 | 解决方案 |
---|---|---|
API 版本 | 最低支持 API 16 | 使用@Component 装饰器,避免高版本 API |
数据量限制 | 大量记录可能影响性能 | 实现分页加载,限制单页显示数量 |
图片处理 | 大图可能导致内存占用过高 | 图片压缩后保存,预览时使用缩略图 |
通过上述实现,一个功能完整、视觉清晰的宝宝成长记录时间轴就构建完成了。该方案基于 HarmonyOS 的 List 组件,通过合理的数据分组和样式设计,既保证了时间序列的直观展示,又具备良好的扩展性,可根据实际需求进一步丰富功能。
更多推荐
所有评论(0)