在育儿类应用中,时间轴是记录宝宝成长轨迹的核心功能。通过时间轴可以直观展示身高、体重等生理指标变化,以及成长过程中的重要事件。本文基于 HarmonyOS 的 List 组件,详细解析如何构建一个支持多类型记录、自动分组排序的成长记录时间轴,包含完整的实现思路、代码示例和优化策略。

一、功能场景与核心需求

宝宝成长记录时间轴需满足以下核心场景:

  • 按日期分组展示成长数据,支持身高体重记录图文日记两种形式
  • 新记录默认按时间倒序排列,最新数据置顶显示
  • 时间轴视觉上需体现时间流逝感,通过连接线和节点强化时序关系
  • 支持新增、查看、编辑记录,数据变化时自动更新时间轴

    1.1 数据类型设计

    记录类型 核心字段 展示形式
    生理指标 日期、身高、体重、记录人 数值卡片 + 趋势提示
    成长日记 日期、标题、内容、图片列表、记录人 图文混排卡片

    二、实现流程与关键技术

    2.1 数据处理流程图

2.2 核心技术点

  1. 日期分组与排序:使用Set去重日期,按时间戳倒序排列
  2. 时间轴视觉实现:通过ListItemGroup头部展示日期节点,ListItem左侧边框作为连接线
  3. 数据响应式更新:使用@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 扩展功能建议

  1. 数据统计:添加身高体重趋势图表,使用Chart组件可视化成长曲线
  2. 记录搜索:按日期、关键词筛选记录,提升数据检索效率
  3. 多媒体支持:日记记录支持拍照 / 相册选择图片,丰富记录形式
  4. 备份与同步:支持将记录同步到云端,避免数据丢失

六、约束与兼容性处理

约束项 具体要求 解决方案
API 版本 最低支持 API 16 使用@Component装饰器,避免高版本 API
数据量限制 大量记录可能影响性能 实现分页加载,限制单页显示数量
图片处理 大图可能导致内存占用过高 图片压缩后保存,预览时使用缩略图

通过上述实现,一个功能完整、视觉清晰的宝宝成长记录时间轴就构建完成了。该方案基于 HarmonyOS 的 List 组件,通过合理的数据分组和样式设计,既保证了时间序列的直观展示,又具备良好的扩展性,可根据实际需求进一步丰富功能。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐