前言

在移动应用开发中,时间输入是一个非常常见的需求。无论是设置闹钟、记录睡眠时间、预约服务,还是填写表单,都需要用户输入时间。传统的键盘输入方式虽然精确,但操作繁琐且容易出错。而TimePicker 组件,配合快捷按钮的设计,可以让用户快速、准确地选择时间,大大提升用户体验。

本文将通过一个实际案例——健康管理应用的睡眠记录功能,带你深入理解 TimePicker 组件的使用方法和时间输入的交互设计技巧。

本文适合已经了解 ArkTS 基础语法的初学者阅读。通过学习本文,你将掌握:

  • TimePicker 组件的基础用法和属性配置
  • 时间数据的格式化与转换
  • 快捷时间按钮的设计模式
  • 时间选择器的显示与隐藏控制
  • 时间计算与验证逻辑
  • 对话框中的时间选择器布局
  • 响应式时间选择器设计

什么是 TimePicker 组件

TimePicker 是时间选择器组件,用于让用户选择具体的时间(小时和分钟)。它采用滚轮式的交互方式,用户可以通过上下滑动来选择时间,比传统的键盘输入更加直观和便捷。

核心特点:

  1. 滚轮式交互:上下滑动选择时间,符合移动端操作习惯
  2. 24小时制:支持 00:00 到 23:59 的时间范围
  3. 实时预览:选择过程中可以实时看到当前时间
  4. 精确到分钟:支持小时和分钟的独立选择
  5. 易于集成:可以嵌入到对话框、表单等任何场景

常见应用场景:

  • 闹钟设置:设置起床闹钟、提醒事项
  • 睡眠记录:记录就寝时间和起床时间
  • 预约服务:选择预约时间、会议时间
  • 日程安排:设置事件开始和结束时间
  • 提醒功能:设置定时提醒的时间

案例背景

我们要实现一个睡眠记录对话框,包含以下功能:

  1. 就寝时间选择:用户可以选择晚上几点睡觉
  2. 起床时间选择:用户可以选择早上几点起床
  3. 快捷时间按钮:提供常用时间(22:00、22:30、23:00 等)快速选择
  4. 自定义时间:点击"自定义"按钮展开 TimePicker 进行精确选择
  5. 时长自动计算:根据就寝和起床时间自动计算睡眠时长
  6. 数据验证:确保起床时间晚于就寝时间

最终效果如下图所示:

初始状态:
初始状态

点击"自定义"后:
自定义

一、完整代码实现

让我们先看睡眠记录对话框的完整实现代码。

@Component
export struct SleepTabContent {
  // 时间相关状态
  @State bedTime: string = '23:00';           // 就寝时间(字符串格式)
  @State wakeTime: string = '07:00';          // 起床时间(字符串格式)
  @State bedTimePicker: Date = new Date();    // TimePicker 的 Date 对象
  @State wakeTimePicker: Date = new Date();   // TimePicker 的 Date 对象
  @State showBedTimePicker: boolean = false;  // 是否显示就寝时间选择器
  @State showWakeTimePicker: boolean = false; // 是否显示起床时间选择器
  @State sleepDuration: number = 480;         // 睡眠时长(分钟)
  @State sleepQuality: number = 3;            // 睡眠质量(1-5)
  @State showRecordDialog: boolean = false;   // 是否显示记录对话框

  private prefService: PreferencesService | null = null;

  aboutToAppear(): void {
    const ctx = getContext(this) as common.UIAbilityContext;
    this.prefService = PreferencesService.getInstance(ctx);
  }

  // 将字符串时间转换为 Date 对象
  private createTimeDate(time: string): Date {
    const now: Date = new Date();
    const parts: string[] = time.split(':');
    const hour: number = parseInt(parts[0]);
    const minute: number = parseInt(parts[1]);
    now.setHours(hour, minute, 0, 0);
    return now;
  }

  // 将 Date 对象转换为字符串时间
  private formatTime(date: Date): string {
    const hour: string = String(date.getHours()).padStart(2, '0');
    const minute: string = String(date.getMinutes()).padStart(2, '0');
    return `${hour}:${minute}`;
  }

  // 将 TimePickerResult 转换为字符串时间
  private formatPickerTime(result: TimePickerResult): string {
    const hour: string = String(result.hour).padStart(2, '0');
    const minute: string = String(result.minute).padStart(2, '0');
    return `${hour}:${minute}`;
  }

  // 计算睡眠时长
  calculateDuration(): void {
    const bedParts: string[] = this.bedTime.split(':');
    const wakeParts: string[] = this.wakeTime.split(':');

    let bedMinutes: number = parseInt(bedParts[0]) * 60 + parseInt(bedParts[1]);
    let wakeMinutes: number = parseInt(wakeParts[0]) * 60 + parseInt(wakeParts[1]);

    // 如果起床时间小于就寝时间,说明跨天了
    if (wakeMinutes < bedMinutes) {
      wakeMinutes += 24 * 60;
    }

    this.sleepDuration = wakeMinutes - bedMinutes;
  }

  // 格式化时长显示
  formatDuration(minutes: number): string {
    const hours: number = Math.floor(minutes / 60);
    const mins: number = minutes % 60;
    return `${hours}小时${mins}分钟`;
  }

  // 保存睡眠记录
  async saveSleepRecord(): Promise<void> {
    if (!this.prefService) return;

    this.calculateDuration();
    const today: string = getTodayDateString();

    const record: SleepRecord = {
      id: Date.now(),
      date: today,
      bedTime: this.bedTime,
      wakeTime: this.wakeTime,
      duration: this.sleepDuration,
      quality: this.sleepQuality
    };

    await this.prefService.saveSleepRecord(record);
    this.showRecordDialog = false;
  }

  build() {
    Stack() {
      // 主页面内容
      Scroll() {
        Column() {
          // 标题栏
          Row() {
            Text('作息管理')
              .fontSize(24)
              .fontWeight(FontWeight.Bold)

            Blank()

            Button('手动记录')
              .fontSize(14)
              .fontColor(Color.White)
              .backgroundColor('#9C27B0')
              .borderRadius(20)
              .onClick((): void => {
                // 初始化时间选择器
                this.bedTimePicker = this.createTimeDate(this.bedTime);
                this.wakeTimePicker = this.createTimeDate(this.wakeTime);
                this.showBedTimePicker = false;
                this.showWakeTimePicker = false;
                this.showRecordDialog = true;
              })
          }
          .width('100%')
          .padding(16)

          // 其他内容...
        }
      }

      // 记录对话框
      if (this.showRecordDialog) {
        this.RecordDialog()
      }
    }
  }

  @Builder
  RecordDialog() {
    Column() {
      Column() {
        // 标题
        Text('记录睡眠')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')

        // 就寝时间选择
        this.TimeSelector(
          '就寝时间',
          this.bedTime,
          ['22:00', '22:30', '23:00', '00:00'],
          this.showBedTimePicker,
          this.bedTimePicker,
          (time: string) => {
            this.bedTime = time;
            this.bedTimePicker = this.createTimeDate(time);
            this.calculateDuration();
          },
          () => {
            this.bedTimePicker = this.createTimeDate(this.bedTime);
            this.showBedTimePicker = true;
            this.showWakeTimePicker = false;
          },
          (value: TimePickerResult) => {
            this.bedTimePicker = this.createTimeDate(this.formatPickerTime(value));
          },
          () => {
            this.showBedTimePicker = false;
          },
          () => {
            this.bedTime = this.formatTime(this.bedTimePicker);
            this.calculateDuration();
            this.showBedTimePicker = false;
          }
        )

        // 起床时间选择
        this.TimeSelector(
          '起床时间',
          this.wakeTime,
          ['06:30', '07:00', '07:30', '08:00'],
          this.showWakeTimePicker,
          this.wakeTimePicker,
          (time: string) => {
            this.wakeTime = time;
            this.wakeTimePicker = this.createTimeDate(time);
            this.calculateDuration();
          },
          () => {
            this.wakeTimePicker = this.createTimeDate(this.wakeTime);
            this.showWakeTimePicker = true;
            this.showBedTimePicker = false;
          },
          (value: TimePickerResult) => {
            this.wakeTimePicker = this.createTimeDate(this.formatPickerTime(value));
          },
          () => {
            this.showWakeTimePicker = false;
          },
          () => {
            this.wakeTime = this.formatTime(this.wakeTimePicker);
            this.calculateDuration();
            this.showWakeTimePicker = false;
          }
        )

        // 睡眠时长显示
        Row() {
          Text('睡眠时长')
            .fontSize(16)
            .fontColor('#333333')
          Blank()
          Text(this.formatDuration(this.sleepDuration))
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .fontColor('#9C27B0')
        }
        .width('100%')
        .margin({ top: 16 })

        // 底部按钮
        Row() {
          Button('取消')
            .layoutWeight(1)
            .height(44)
            .backgroundColor('#F5F5F5')
            .fontColor('#333333')
            .onClick(() => {
              this.showRecordDialog = false;
            })

          Button('保存')
            .layoutWeight(1)
            .height(44)
            .backgroundColor('#9C27B0')
            .fontColor(Color.White)
            .margin({ left: 12 })
            .onClick(() => {
              this.saveSleepRecord();
            })
        }
        .width('100%')
        .margin({ top: 20 })
      }
      .width('85%')
      .padding(24)
      .backgroundColor(Color.White)
      .borderRadius(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0, 0, 0, 0.5)')
    .justifyContent(FlexAlign.Center)
  }

  @Builder
  TimeSelector(
    label: string,
    currentTime: string,
    quickTimes: string[],
    showPicker: boolean,
    pickerDate: Date,
    onQuickSelect: (time: string) => void,
    onShowPicker: () => void,
    onPickerChange: (value: TimePickerResult) => void,
    onPickerCancel: () => void,
    onPickerConfirm: () => void
  ) {
    Column() {
      // 标签和当前时间
      Row() {
        Text(label)
          .fontSize(16)
          .fontColor('#333333')
        Blank()
        Text(currentTime)
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#9C27B0')
      }
      .width('100%')
      .margin({ top: 16 })

      // 快捷时间按钮
      Row() {
        ForEach(quickTimes, (time: string) => {
          Text(time)
            .fontSize(12)
            .fontColor(currentTime === time ? Color.White : '#333333')
            .backgroundColor(currentTime === time ? '#9C27B0' : '#F5F5F5')
            .padding({ left: 8, right: 8, top: 6, bottom: 6 })
            .borderRadius(8)
            .onClick(() => {
              onQuickSelect(time);
            })
        })

        Text('自定义')
          .fontSize(12)
          .fontColor(showPicker ? Color.White : '#333333')
          .backgroundColor(showPicker ? '#9C27B0' : '#F5F5F5')
          .padding({ left: 8, right: 8, top: 6, bottom: 6 })
          .borderRadius(8)
          .onClick(() => {
            onShowPicker();
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 8 })

      // TimePicker(条件显示)
      if (showPicker) {
        Column() {
          TimePicker({ selected: pickerDate })
            .onChange((value: TimePickerResult) => {
              onPickerChange(value);
            })

          Row() {
            Button('取消')
              .fontSize(14)
              .backgroundColor('#F5F5F5')
              .fontColor('#333333')
              .onClick(() => {
                onPickerCancel();
              })

            Button('确定')
              .fontSize(14)
              .backgroundColor('#9C27B0')
              .fontColor(Color.White)
              .margin({ left: 8 })
              .onClick(() => {
                onPickerConfirm();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.End)
          .margin({ top: 8 })
        }
        .width('100%')
        .padding(12)
        .backgroundColor('#F9F9F9')
        .borderRadius(8)
        .margin({ top: 8 })
      }
    }
  }
}

二、TimePicker 组件基础知识

2.1 TimePicker 的基本用法

最简单的 TimePicker:

TimePicker({ selected: new Date() })

这会创建一个时间选择器,默认选中当前时间。

核心参数说明:

参数 类型 说明 示例
selected Date 当前选中的时间 selected: new Date()

注意: TimePicker 只接受 Date 对象作为参数,不能直接传入字符串时间。

2.2 TimePicker 的事件处理

TimePicker 的核心事件是 onChange,当用户滑动选择时间时触发。

基础用法:

@State selectedTime: Date = new Date();

TimePicker({ selected: this.selectedTime })
  .onChange((value: TimePickerResult) => {
    console.log(`选中的时间:${value.hour}:${value.minute}`);
  })

TimePickerResult 接口:

interface TimePickerResult {
  hour: number;    // 小时(0-23)
  minute: number;  // 分钟(0-59)
}

实时更新示例:

@State hour: number = 12;
@State minute: number = 0;

Column() {
  Text(`当前时间:${this.hour}:${this.minute}`)
    .fontSize(18)
  
  TimePicker({ selected: new Date() })
    .onChange((value: TimePickerResult) => {
      this.hour = value.hour;
      this.minute = value.minute;
    })
}

2.3 时间格式的转换

在实际开发中,我们通常需要在三种格式之间转换:

  1. 字符串格式"23:00"(用于显示和存储)
  2. Date 对象new Date()(用于 TimePicker)
  3. TimePickerResult{ hour: 23, minute: 0 }(TimePicker 的回调)

转换方法封装:

// 1. 字符串 → Date 对象
private createTimeDate(time: string): Date {
  const now: Date = new Date();
  const parts: string[] = time.split(':');
  const hour: number = parseInt(parts[0]);
  const minute: number = parseInt(parts[1]);
  now.setHours(hour, minute, 0, 0);
  return now;
}

// 2. Date 对象 → 字符串
private formatTime(date: Date): string {
  const hour: string = String(date.getHours()).padStart(2, '0');
  const minute: string = String(date.getMinutes()).padStart(2, '0');
  return `${hour}:${minute}`;
}

// 3. TimePickerResult → 字符串
private formatPickerTime(result: TimePickerResult): string {
  const hour: string = String(result.hour).padStart(2, '0');
  const minute: string = String(result.minute).padStart(2, '0');
  return `${hour}:${minute}`;
}

为什么需要 padStart(2, ‘0’)?

// ❌ 不使用 padStart
const hour = 9;
const minute = 5;
const time = `${hour}:${minute}`;  // "9:5"(不规范)

// ✅ 使用 padStart
const hour = String(9).padStart(2, '0');
const minute = String(5).padStart(2, '0');
const time = `${hour}:${minute}`;  // "09:05"(规范)

padStart(2, '0') 确保数字始终是两位数,不足两位时在前面补 0。

2.4 完整的时间选择流程

@Component
struct TimePickerDemo {
  @State displayTime: string = '12:00';        // 显示的时间(字符串)
  @State pickerTime: Date = new Date();        // TimePicker 的时间(Date)
  @State showPicker: boolean = false;          // 是否显示 TimePicker

  build() {
    Column() {
      // 1. 显示当前时间
      Text(`当前时间:${this.displayTime}`)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      // 2. 点击按钮显示 TimePicker
      Button('选择时间')
        .onClick(() => {
          // 将字符串时间转换为 Date 对象
          this.pickerTime = this.createTimeDate(this.displayTime);
          this.showPicker = true;
        })

      // 3. TimePicker(条件显示)
      if (this.showPicker) {
        Column() {
          TimePicker({ selected: this.pickerTime })
            .onChange((value: TimePickerResult) => {
              // 实时更新 pickerTime
              const timeStr = this.formatPickerTime(value);
              this.pickerTime = this.createTimeDate(timeStr);
            })

          Row() {
            Button('取消')
              .onClick(() => {
                this.showPicker = false;
              })

            Button('确定')
              .onClick(() => {
                // 将 Date 对象转换为字符串
                this.displayTime = this.formatTime(this.pickerTime);
                this.showPicker = false;
              })
          }
        }
      }
    }
  }

  private createTimeDate(time: string): Date {
    const now: Date = new Date();
    const parts: string[] = time.split(':');
    now.setHours(parseInt(parts[0]), parseInt(parts[1]), 0, 0);
    return now;
  }

  private formatTime(date: Date): string {
    const hour: string = String(date.getHours()).padStart(2, '0');
    const minute: string = String(date.getMinutes()).padStart(2, '0');
    return `${hour}:${minute}`;
  }

  private formatPickerTime(result: TimePickerResult): string {
    const hour: string = String(result.hour).padStart(2, '0');
    const minute: string = String(result.minute).padStart(2, '0');
    return `${hour}:${minute}`;
  }
}

流程:

用户点击"选择时间"
    ↓
将 displayTime(字符串)转换为 pickerTime(Date)
    ↓
显示 TimePicker
    ↓
用户滑动选择时间
    ↓
onChange 回调,更新 pickerTime
    ↓
用户点击"确定"
    ↓
将 pickerTime(Date)转换为 displayTime(字符串)
    ↓
隐藏 TimePicker,显示新时间

三、快捷时间按钮设计

3.1 为什么需要快捷按钮

虽然 TimePicker 可以精确选择任意时间,但在实际使用中,用户往往会选择一些常用的时间点。例如:

  • 就寝时间:22:00、22:30、23:00、00:00
  • 起床时间:06:30、07:00、07:30、08:00
  • 闹钟时间:06:00、07:00、08:00、09:00

提供快捷按钮可以让用户一键选择这些常用时间,无需滑动 TimePicker,大大提升操作效率。

用户体验对比:

❌ 只有 TimePicker:
用户想选择 23:00
→ 打开 TimePicker
→ 滑动小时到 23
→ 滑动分钟到 00
→ 点击确定
(需要 4 步操作)

✅ 有快捷按钮:
用户想选择 23:00
→ 点击 [23:00] 按钮
(只需 1 步操作)

3.2 快捷按钮的实现

基础实现:

@State bedTime: string = '23:00';

Row() {
  ForEach(['22:00', '22:30', '23:00', '00:00'], (time: string) => {
    Text(time)
      .fontSize(12)
      .fontColor(this.bedTime === time ? Color.White : '#333333')
      .backgroundColor(this.bedTime === time ? '#9C27B0' : '#F5F5F5')
      .padding({ left: 8, right: 8, top: 6, bottom: 6 })
      .borderRadius(8)
      .onClick(() => {
        this.bedTime = time;
        this.calculateDuration();  // 更新睡眠时长
      })
  })
}
.justifyContent(FlexAlign.SpaceBetween)

视觉效果:

视觉效果

3.3 快捷时间的选择原则

就寝时间的快捷选项:

const bedTimeOptions = ['22:00', '22:30', '23:00', '00:00'];

选择依据:

  • 22:00:早睡派,健康作息
  • 22:30:比较常见的就寝时间
  • 23:00:大多数人的就寝时间
  • 00:00:晚睡派,熬夜党

起床时间的快捷选项:

const wakeTimeOptions = ['06:30', '07:00', '07:30', '08:00'];

选择依据:

  • 06:30:早起派,晨练或早课
  • 07:00:标准起床时间
  • 07:30:稍晚一点的起床时间
  • 08:00:周末或假期的起床时间

设计原则:

  1. 覆盖常见场景:选择大多数用户会用到的时间点
  2. 间隔合理:通常 30 分钟一个档位
  3. 数量适中:3-5 个选项,不要太多
  4. 留有自定义:提供"自定义"按钮满足特殊需求

3.4 "自定义"按钮的设计

"自定义"按钮用于展开 TimePicker,让用户精确选择时间。

实现代码:

@State showBedTimePicker: boolean = false;

Row() {
  // 快捷时间按钮
  ForEach(['22:00', '22:30', '23:00', '00:00'], (time: string) => {
    Text(time)
      .fontSize(12)
      .fontColor(this.bedTime === time ? Color.White : '#333333')
      .backgroundColor(this.bedTime === time ? '#9C27B0' : '#F5F5F5')
      .padding({ left: 8, right: 8, top: 6, bottom: 6 })
      .borderRadius(8)
      .onClick(() => {
        this.bedTime = time;
        this.bedTimePicker = this.createTimeDate(time);
        this.calculateDuration();
      })
  })

  // 自定义按钮
  Text('自定义')
    .fontSize(12)
    .fontColor(this.showBedTimePicker ? Color.White : '#333333')
    .backgroundColor(this.showBedTimePicker ? '#9C27B0' : '#F5F5F5')
    .padding({ left: 8, right: 8, top: 6, bottom: 6 })
    .borderRadius(8)
    .onClick(() => {
      this.bedTimePicker = this.createTimeDate(this.bedTime);
      this.showBedTimePicker = true;
      this.showWakeTimePicker = false;  // 关闭另一个选择器
    })
}

交互逻辑:

  1. 点击快捷按钮

    • 直接设置时间
    • 关闭 TimePicker(如果已打开)
    • 更新相关计算(如睡眠时长)
  2. 点击"自定义"按钮

    • 初始化 TimePicker 为当前时间
    • 显示 TimePicker
    • 关闭另一个 TimePicker(避免同时显示两个)

四、时间选择器的状态管理

4.1 状态变量的设计

在时间选择器的实现中,我们需要管理多个状态变量:

@State bedTime: string = '23:00';           // 就寝时间(显示用)
@State wakeTime: string = '07:00';          // 起床时间(显示用)
@State bedTimePicker: Date = new Date();    // 就寝时间选择器的 Date
@State wakeTimePicker: Date = new Date();   // 起床时间选择器的 Date
@State showBedTimePicker: boolean = false;  // 是否显示就寝时间选择器
@State showWakeTimePicker: boolean = false; // 是否显示起床时间选择器
@State sleepDuration: number = 480;         // 睡眠时长(分钟)

为什么需要两套时间变量?

bedTime(字符串)          bedTimePicker(Date)
    ↓                           ↓
用于显示和存储              用于 TimePicker 组件
"23:00"                     Date 对象

原因:

  1. 显示需要字符串:UI 上显示 “23:00” 比显示 Date 对象更直观
  2. TimePicker 需要 Date:TimePicker 组件只接受 Date 对象
  3. 存储需要字符串:保存到数据库时,字符串更方便

4.2 显示与隐藏的控制

单个选择器的控制:

@State showPicker: boolean = false;

// 显示选择器
Button('选择时间')
  .onClick(() => {
    this.showPicker = true;
  })

// 隐藏选择器
if (this.showPicker) {
  Column() {
    TimePicker({ selected: this.pickerTime })
    
    Button('确定')
      .onClick(() => {
        this.showPicker = false;  // 隐藏
      })
  }
}

多个选择器的互斥控制:

当页面上有多个时间选择器时(如就寝时间和起床时间),需要确保同时只显示一个。

@State showBedTimePicker: boolean = false;
@State showWakeTimePicker: boolean = false;

// 显示就寝时间选择器
Button('选择就寝时间')
  .onClick(() => {
    this.showBedTimePicker = true;
    this.showWakeTimePicker = false;  // 关闭另一个
  })

// 显示起床时间选择器
Button('选择起床时间')
  .onClick(() => {
    this.showWakeTimePicker = true;
    this.showBedTimePicker = false;  // 关闭另一个
  })

4.3 时间数据的同步

时间选择器涉及多个状态变量的同步更新,需要仔细处理。

完整的同步流程:

// 1. 点击快捷按钮
Button('23:00')
  .onClick(() => {
    this.bedTime = '23:00';                          // 更新显示时间
    this.bedTimePicker = this.createTimeDate('23:00'); // 同步 Date 对象
    this.calculateDuration();                         // 重新计算时长
  })

// 2. 点击"自定义"按钮
Button('自定义')
  .onClick(() => {
    this.bedTimePicker = this.createTimeDate(this.bedTime); // 初始化为当前时间
    this.showBedTimePicker = true;                          // 显示选择器
  })

// 3. 滑动 TimePicker
TimePicker({ selected: this.bedTimePicker })
  .onChange((value: TimePickerResult) => {
    // 实时更新 Date 对象
    this.bedTimePicker = this.createTimeDate(this.formatPickerTime(value));
  })

// 4. 点击"确定"按钮
Button('确定')
  .onClick(() => {
    this.bedTime = this.formatTime(this.bedTimePicker); // 更新显示时间
    this.calculateDuration();                            // 重新计算时长
    this.showBedTimePicker = false;                      // 隐藏选择器
  })

4.4 初始化时机

时间选择器的初始化时机很重要,需要在正确的时机设置初始值。

错误的初始化:

// ❌ 在组件创建时初始化
@State bedTimePicker: Date = this.createTimeDate(this.bedTime);
// 问题:此时 bedTime 可能还没有值,或者是默认值

正确的初始化:

// ✅ 在打开对话框时初始化
Button('手动记录')
  .onClick(() => {
    // 此时 bedTime 已经有正确的值
    this.bedTimePicker = this.createTimeDate(this.bedTime);
    this.wakeTimePicker = this.createTimeDate(this.wakeTime);
    this.showBedTimePicker = false;
    this.showWakeTimePicker = false;
    this.showRecordDialog = true;
  })

为什么要在打开对话框时初始化?

  1. 确保数据正确:此时 bedTime 和 wakeTime 已经从数据库加载
  2. 避免旧数据:每次打开对话框都重新初始化,不会显示上次的数据
  3. 重置选择器状态:关闭所有 TimePicker,避免上次的状态残留

五、时间计算与验证

5.1 睡眠时长的计算

睡眠时长的计算需要考虑跨天的情况。

基础计算逻辑:

calculateDuration(): void {
  const bedParts: string[] = this.bedTime.split(':');
  const wakeParts: string[] = this.wakeTime.split(':');

  // 将时间转换为分钟数
  let bedMinutes: number = parseInt(bedParts[0]) * 60 + parseInt(bedParts[1]);
  let wakeMinutes: number = parseInt(wakeParts[0]) * 60 + parseInt(wakeParts[1]);

  // 如果起床时间小于就寝时间,说明跨天了
  if (wakeMinutes < bedMinutes) {
    wakeMinutes += 24 * 60;  // 加上一天的分钟数
  }

  this.sleepDuration = wakeMinutes - bedMinutes;
}

为什么要判断跨天?

❌ 不判断跨天的问题:
就寝:23:00(1380分钟)
起床:07:00(420分钟)
时长:420 - 1380 = -960 分钟 ❌(负数!)

✅ 判断跨天后:
就寝:23:00(1380分钟)
起床:07:00(420 + 1440 = 1860分钟)
时长:1860 - 1380 = 480 分钟 ✅(8小时)

5.2 时长的格式化显示

计算出的时长是分钟数,需要转换为易读的格式。

格式化方法:

formatDuration(minutes: number): string {
  const hours: number = Math.floor(minutes / 60);
  const mins: number = minutes % 60;
  return `${hours}小时${mins}分钟`;
}

显示示例:

this.formatDuration(480)  // "8小时0分钟"
this.formatDuration(495)  // "8小时15分钟"
this.formatDuration(90)   // "1小时30分钟"
this.formatDuration(45)   // "0小时45分钟"

优化版本(更简洁):

formatDuration(minutes: number): string {
  const hours: number = Math.floor(minutes / 60);
  const mins: number = minutes % 60;
  
  if (mins === 0) {
    return `${hours}小时`;
  }
  return `${hours}小时${mins}分钟`;
}

优化后的显示:

this.formatDuration(480)  // "8小时"(更简洁)
this.formatDuration(495)  // "8小时15分钟"
this.formatDuration(90)   // "1小时30分钟"
this.formatDuration(45)   // "0小时45分钟"

5.3 时间的验证

在实际应用中,需要对用户输入的时间进行验证。

常见验证场景:

1. 睡眠时长过短:

validateSleepDuration(): boolean {
  if (this.sleepDuration < 60) {  // 少于1小时
    AlertDialog.show({
      title: '提示',
      message: '睡眠时长过短,建议至少睡1小时',
      confirm: {
        value: '知道了',
        action: () => {}
      }
    });
    return false;
  }
  return true;
}

2. 睡眠时长过长:

if (this.sleepDuration > 16 * 60) {  // 超过16小时
  AlertDialog.show({
    title: '提示',
    message: '睡眠时长过长,请检查时间是否正确',
    confirm: {
      value: '知道了',
      action: () => {}
    }
  });
  return false;
}

3. 时间合理性检查:

validateTimeRange(): boolean {
  const bedHour = parseInt(this.bedTime.split(':')[0]);
  const wakeHour = parseInt(this.wakeTime.split(':')[0]);
  
  // 就寝时间应该在晚上(18:00-次日6:00)
  if (bedHour >= 6 && bedHour < 18) {
    AlertDialog.show({
      title: '提示',
      message: '就寝时间通常在晚上,请检查是否正确',
      confirm: {
        value: '确定',
        action: () => {}
      },
      cancel: () => {}
    });
    return false;
  }
  
  return true;
}

完整的保存流程:

async saveSleepRecord(): Promise<void> {
  // 1. 计算时长
  this.calculateDuration();
  
  // 2. 验证时长
  if (!this.validateSleepDuration()) {
    return;
  }
  
  // 3. 验证时间范围
  if (!this.validateTimeRange()) {
    return;
  }
  
  // 4. 保存数据
  if (!this.prefService) return;
  
  const today: string = getTodayDateString();
  const record: SleepRecord = {
    id: Date.now(),
    date: today,
    bedTime: this.bedTime,
    wakeTime: this.wakeTime,
    duration: this.sleepDuration,
    quality: this.sleepQuality
  };
  
  await this.prefService.saveSleepRecord(record);
  this.showRecordDialog = false;
}

5.4 实时计算的触发时机

睡眠时长需要在时间变化时实时更新。

触发时机:

// 1. 点击快捷按钮时
Button('23:00')
  .onClick(() => {
    this.bedTime = '23:00';
    this.calculateDuration();  // ← 触发计算
  })

// 2. TimePicker 确定时
Button('确定')
  .onClick(() => {
    this.bedTime = this.formatTime(this.bedTimePicker);
    this.calculateDuration();  // ← 触发计算
    this.showBedTimePicker = false;
  })

// 3. 使用"现在"按钮时
Button('现在')
  .onClick(() => {
    const nowTime: string = getCurrentTimeString();
    this.wakeTime = nowTime;
    this.calculateDuration();  // ← 触发计算
  })

实时显示效果:

初始状态:
就寝时间:23:00
起床时间:07:00
睡眠时长:8小时0分钟

点击就寝时间 [22:30]:
就寝时间:22:30  ← 变化
起床时间:07:00
睡眠时长:8小时30分钟  ← 自动更新

点击起床时间 [07:30]:
就寝时间:22:30
起床时间:07:30  ← 变化
睡眠时长:9小时0分钟  ← 自动更新

六、对话框中的时间选择器布局

6.1 对话框的基本结构

时间选择器通常放在对话框中,对话框的布局需要精心设计。

代码实现:

build() {
  Stack() {
    // 底层:主页面内容
    Scroll() {
      Column() {
        // 页面内容
      }
    }

    // 顶层:对话框
    if (this.showRecordDialog) {
      Column() {
        // 遮罩层(半透明黑色)
        Column() {
          // 对话框内容(白色卡片)
        }
        .width('85%')
        .padding(24)
        .backgroundColor(Color.White)
        .borderRadius(16)
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(0, 0, 0, 0.5)')  // 半透明遮罩
      .justifyContent(FlexAlign.Center)
    }
  }
}

6.2 时间选择区域的布局

每个时间选择区域包含:标签、当前时间、快捷按钮、TimePicker。

代码实现:

Column() {
  // 1. 标签和当前时间
  Row() {
    Text('就寝时间')
      .fontSize(16)
      .fontColor('#333333')
    Blank()
    Text(this.bedTime)
      .fontSize(18)
      .fontWeight(FontWeight.Medium)
      .fontColor('#9C27B0')
  }
  .width('100%')

  // 2. 快捷按钮
  Row() {
    ForEach(['22:00', '22:30', '23:00', '00:00'], (time: string) => {
      Text(time)
        .fontSize(12)
        .fontColor(this.bedTime === time ? Color.White : '#333333')
        .backgroundColor(this.bedTime === time ? '#9C27B0' : '#F5F5F5')
        .padding({ left: 8, right: 8, top: 6, bottom: 6 })
        .borderRadius(8)
        .onClick(() => {
          this.bedTime = time;
          this.bedTimePicker = this.createTimeDate(time);
          this.calculateDuration();
        })
    })

    Text('自定义')
      .fontSize(12)
      .fontColor(this.showBedTimePicker ? Color.White : '#333333')
      .backgroundColor(this.showBedTimePicker ? '#9C27B0' : '#F5F5F5')
      .padding({ left: 8, right: 8, top: 6, bottom: 6 })
      .borderRadius(8)
      .onClick(() => {
        this.bedTimePicker = this.createTimeDate(this.bedTime);
        this.showBedTimePicker = true;
        this.showWakeTimePicker = false;
      })
  }
  .width('100%')
  .justifyContent(FlexAlign.SpaceBetween)
  .margin({ top: 8 })

  // 3. TimePicker(条件显示)
  if (this.showBedTimePicker) {
    Column() {
      TimePicker({ selected: this.bedTimePicker })
        .onChange((value: TimePickerResult) => {
          this.bedTimePicker = this.createTimeDate(this.formatPickerTime(value));
        })

      Row() {
        Button('取消')
          .fontSize(14)
          .backgroundColor('#F5F5F5')
          .fontColor('#333333')
          .onClick(() => {
            this.showBedTimePicker = false;
          })

        Button('确定')
          .fontSize(14)
          .backgroundColor('#9C27B0')
          .fontColor(Color.White)
          .margin({ left: 8 })
          .onClick(() => {
            this.bedTime = this.formatTime(this.bedTimePicker);
            this.calculateDuration();
            this.showBedTimePicker = false;
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#F9F9F9')
    .borderRadius(8)
    .margin({ top: 8 })
  }
}

6.3 底部按钮的布局

对话框底部通常有两个按钮:取消和确定。

等宽布局:

Row() {
  Button('取消')
    .layoutWeight(1)
    .height(44)
    .backgroundColor('#F5F5F5')
    .fontColor('#333333')
    .onClick(() => {
      this.showRecordDialog = false;
    })

  Button('保存')
    .layoutWeight(1)
    .height(44)
    .backgroundColor('#9C27B0')
    .fontColor(Color.White)
    .margin({ left: 12 })
    .onClick(() => {
      this.saveSleepRecord();
    })
}
.width('100%')
.margin({ top: 20 })

视觉效果:

效果

按钮样式区分:

按钮类型 背景色 文字色 用途
取消按钮 浅灰色 深色 次要操作,放弃当前操作
确定按钮 主题色 白色 主要操作,完成当前任务

总结

通过本文的学习,我们深入了解了 TimePicker 组件的使用方法和时间选择器的交互设计。让我们回顾一下核心要点:

TimePicker 组件的核心知识:

  1. TimePicker 使用 Date 对象作为参数,通过 onChange 回调获取用户选择
  2. 需要在字符串、Date 对象、TimePickerResult 三种格式之间灵活转换
  3. 使用 padStart 确保时间格式的规范性(如 “09:05”)

时间选择器的交互设计:

  1. 快捷按钮 + 自定义选择的组合模式,兼顾效率和灵活性
  2. 多个选择器需要互斥显示,避免界面混乱
  3. 实时计算和显示相关数据(如睡眠时长),提升用户体验

时间计算的注意事项:

  1. 必须处理跨天的情况,避免计算出负数
  2. 将时间转换为分钟数进行计算,更简单可靠
  3. 提供友好的时长显示格式(如 “8小时30分钟”)

时间选择器是移动应用中非常常见的组件,掌握其设计和实现技巧,可以大大提升应用的用户体验。希望本文能帮助你在实际项目中灵活运用 TimePicker 组件,打造出优雅、易用的时间输入界面。

如果有任何问题或建议,欢迎交流讨论。

Logo

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

更多推荐