【HarmonyOS 6】HarmonyOS 自定义时间选择器实现

前言

在开发时间管理类应用时,时间选择器是一个非常常见的功能。本文将通过近期接触的一个实际案例,详细讲解如何在 HarmonyOS 应用中实现一个自定义的时间选择器。我们这个案例中的选择器支持半小时为单位的时间选择,适合用于记录时间块等场景。

本教程适合 HarmonyOS 初学者阅读,你将学习到:

  • 如何创建自定义对话框(CustomDialog)
  • 如何使用 TextPicker 组件
  • 如何管理对话框的状态
  • 如何在父组件中调用对话框并获取返回值

应用场景

在本应用中,用户需要记录已经完成的时间块。当用户点击"记录时间"按钮时,会弹出一个对话框,让用户选择开始时间和结束时间。

注:时间选择器支持小时和分钟的独立选择,分钟只能选择 00 或 30

核心知识点

1. CustomDialog 自定义对话框

在 HarmonyOS 中,@CustomDialog 装饰器用于创建自定义对话框。与系统提供的标准对话框不同,自定义对话框可以完全控制布局和交互逻辑。

typescript 复制代码
@CustomDialog
struct TimePickerDialogContent {
  controller: CustomDialogController  // 对话框控制器
  // ... 其他属性和方法
}

2. CustomDialogController 对话框控制器

CustomDialogController 用于控制对话框的显示和关闭。在父组件中创建控制器实例,然后调用 open() 方法显示对话框。

typescript 复制代码
private timePickerController: CustomDialogController | null = null

// 创建并打开对话框
this.timePickerController = new CustomDialogController({
  builder: TimePickerDialogContent({ /* 参数 */ }),
  autoCancel: true,
  alignment: DialogAlignment.Center
})
this.timePickerController.open()
  • builder:自定义弹窗内容构造器。
  • autoCancel:是否允许点击遮障层退出,true表示关闭弹窗。false表示不关闭弹窗。
  • alignment:弹窗在竖直方向上的对齐方式。

3. @State 状态管理

@State 装饰器用于声明组件的状态变量。当状态变量的值发生变化时,UI 会自动更新。

typescript 复制代码
@State tempHour: number = 0      // 临时存储选中的小时
@State tempMinute: number = 0    // 临时存储选中的分钟

完整实现

第一步:定义对话框结构

首先,我们创建一个自定义对话框组件,定义它需要的属性:

typescript 复制代码
@CustomDialog
struct TimePickerDialogContent {
  controller: CustomDialogController           // 对话框控制器(必需)
  selectedTime: Date = new Date()              // 当前选中的时间
  onConfirm: (hour: number, minute: number) => void = () => {}  // 确认回调
  
  @State tempHour: number = 0                  // 临时小时值
  @State tempMinute: number = 0                // 临时分钟值
  
  // 小时选项(0-23)
  private hours: string[] = [
    '00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11',
    '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23'
  ]
  
  // 分钟选项(只有 00 和 30)
  private minutes: string[] = ['00', '30']
  
  // ... 后续代码
}

代码说明:

  • controller:对话框控制器,用于关闭对话框
  • selectedTime:父组件传入的初始时间
  • onConfirm:确认按钮的回调函数,将选中的时间传回父组件
  • tempHourtempMinute:使用 @State 装饰,当用户滑动选择器时,这些值会实时更新
  • hoursminutes:提供给 TextPicker 的选项数组

第二步:初始化时间值

在对话框显示之前,我们需要将传入的 selectedTime 转换为小时和分钟,并调整到最近的半小时:

typescript 复制代码
aboutToAppear(): void {
  // 获取小时(0-23)
  this.tempHour = this.selectedTime.getHours()
  
  // 将分钟调整为最近的半小时
  const minutes = this.selectedTime.getMinutes()
  
  if (minutes < 15) {
    // 0-14分钟 → 向下取整到 00
    this.tempMinute = 0
  } else if (minutes < 45) {
    // 15-44分钟 → 向上取整到 30
    this.tempMinute = 30
  } else {
    // 45-59分钟 → 向上取整到下一个小时的 00
    this.tempHour = (this.tempHour + 1) % 24  // % 24 确保不超过 23
    this.tempMinute = 0
  }
}

代码说明:

  • aboutToAppear() 是组件生命周期方法,在组件即将显示时调用
  • 我们将任意分钟值调整为 00 或 30,这样用户看到的初始值就是半小时对齐的
  • 使用 % 24 确保小时值在 0-23 范围内(例如 23 点 50 分会变成 0 点 00 分)

第三步:构建 UI 布局

接下来,我们使用 build() 方法构建对话框的 UI:

typescript 复制代码
build() {
  Column() {
    // 标题
    Text('选择时间')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'))
      .margin({ bottom: 12 })
    
    // 提示文字
    Text('仅支持半小时为单位')
      .fontSize(12)
      .fontColor($r('app.color.text_secondary'))
      .margin({ bottom: 16 })

    // 时间选择器区域
    Row() {
      // 小时选择器
      Column() {
        Text('小时')
          .fontSize(12)
          .fontColor($r('app.color.text_secondary'))
          .margin({ bottom: 8 })
        
        TextPicker({ 
          range: this.hours,           // 选项数组
          selected: this.tempHour      // 默认选中项的索引
        })
          .onChange((value: string | string[], index: number | number[]) => {
            // 当用户滑动选择器时触发
            if (typeof index === 'number') {
              this.tempHour = index  // 更新小时值
            }
          })
      }
      .layoutWeight(1)  // 占据一半宽度

      // 分隔符
      Text(':')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.text_primary'))
        .margin({ left: 12, right: 12 })

      // 分钟选择器
      Column() {
        Text('分钟')
          .fontSize(12)
          .fontColor($r('app.color.text_secondary'))
          .margin({ bottom: 8 })
        
        TextPicker({ 
          range: this.minutes,
          selected: this.tempMinute === 0 ? 0 : 1  // 0分钟→索引0,30分钟→索引1
        })
          .onChange((value: string | string[], index: number | number[]) => {
            if (typeof index === 'number') {
              // 将索引转换为实际分钟值
              this.tempMinute = index === 0 ? 0 : 30
            }
          })
      }
      .layoutWeight(1)
    }
    .width('100%')
    .margin({ bottom: 16 })

    // 显示当前选中的时间
    Text(`已选择: ${this.tempHour.toString().padStart(2, '0')}:${this.tempMinute.toString().padStart(2, '0')}`)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.primary_color'))
      .margin({ bottom: 16 })

    // 按钮区域
    Row() {
      // 取消按钮
      Button('取消')
        .fontSize(15)
        .fontColor($r('app.color.text_secondary'))
        .backgroundColor($r('app.color.input_background'))
        .borderRadius(22)
        .layoutWeight(1)
        .height(44)
        .onClick(() => {
          this.controller.close()  // 关闭对话框,不返回任何值
        })

      // 确定按钮
      Button('确定')
        .fontSize(15)
        .fontColor(Color.White)
        .backgroundColor($r('app.color.primary_color'))
        .borderRadius(22)
        .layoutWeight(1)
        .height(44)
        .margin({ left: 10 })
        .onClick(() => {
          // 调用回调函数,将选中的时间传回父组件
          this.onConfirm(this.tempHour, this.tempMinute)
          this.controller.close()  // 关闭对话框
        })
    }
    .width('100%')
  }
  .width('75%')                                      // 对话框宽度为屏幕的 75%
  .padding(20)
  .backgroundColor($r('app.color.card_background'))  // 使用主题颜色
  .borderRadius(16)                                  // 圆角
}

代码说明:

  1. TextPicker 组件

    • range:选项数组,可以是字符串数组
    • selected:默认选中项的索引(从 0 开始)
    • onChange:当用户滑动选择器时触发,参数 index 是选中项的索引
  2. layoutWeight 属性

    • 用于在 Row 或 Column 中分配剩余空间
    • 两个 Column 都设置为 1,表示平分宽度
  3. padStart() 方法

    • 用于在字符串前面补零,例如 '5'.padStart(2, '0') 返回 '05'
    • 确保时间显示为两位数格式

第四步:在父组件中使用

现在,我们在父组件中创建并打开这个时间选择器:

typescript 复制代码
@Component
export struct AddTimeBlockDialog {
  @State startTime: Date = new Date()  // 开始时间
  @State endTime: Date = new Date()    // 结束时间
  
  private startTimePickerController: CustomDialogController | null = null
  
  // 格式化时间显示(例如:14:30)
  private formatTime(date: Date): string {
    const hours = date.getHours().toString().padStart(2, '0')
    const minutes = date.getMinutes().toString().padStart(2, '0')
    return `${hours}:${minutes}`
  }
  
  // 打开开始时间选择器
  private openStartTimePicker(): void {
    this.startTimePickerController = new CustomDialogController({
      builder: TimePickerDialogContent({
        selectedTime: this.startTime,  // 传入当前时间
        onConfirm: (hour: number, minute: number) => {
          // 用户点击确定后,更新开始时间
          const newTime = new Date(this.startTime)
          newTime.setHours(hour)
          newTime.setMinutes(minute)
          newTime.setSeconds(0)
          newTime.setMilliseconds(0)
          this.startTime = newTime  // 更新状态,UI 会自动刷新
        }
      }),
      autoCancel: true,                    // 点击对话框外部自动关闭
      alignment: DialogAlignment.Center,   // 居中显示
      customStyle: true                    // 使用自定义样式
    })
    this.startTimePickerController.open()  // 显示对话框
  }
  
  build() {
    Column() {
      // 开始时间按钮
      Button() {
        Column() {
          Text('开始')
            .fontSize(11)
            .fontColor($r('app.color.text_secondary'))
          Text(this.formatTime(this.startTime))
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor($r('app.color.text_primary'))
            .margin({ top: 2 })
        }
      }
      .backgroundColor($r('app.color.input_background'))
      .borderRadius(10)
      .padding(12)
      .onClick(() => {
        this.openStartTimePicker()  // 点击按钮打开选择器
      })
    }
  }
}

代码说明:

  1. CustomDialogController 配置

    • builder:指定对话框的内容组件
    • autoCancel:是否允许点击外部关闭
    • alignment:对话框在屏幕上的位置
    • customStyle:是否使用自定义样式(如果为 false,会使用系统默认样式)
  2. 回调函数

    • onConfirm 是一个箭头函数,当用户点击确定时被调用
    • 我们在回调中更新 startTime 状态,UI 会自动刷新显示新时间
  3. Date 对象操作

    • 创建新的 Date 对象:new Date(this.startTime)
    • 设置小时和分钟:setHours()setMinutes()
    • 清零秒和毫秒:确保时间精确到分钟

关键技术点详解

1. 为什么使用 CustomDialog 而不是系统对话框?

HarmonyOS 提供了 TimePickerDialog 系统对话框,但它不支持限制分钟选择(例如只能选择 00 或 30)。通过自定义对话框,我们可以:

  • 完全控制 UI 样式
  • 自定义选择范围
  • 添加额外的提示信息
  • 实现特殊的业务逻辑

2. 为什么需要 tempHour 和 tempMinute?

我们不能直接修改 selectedTime,因为:

  • 用户可能会点击"取消"按钮,此时不应该修改原始时间
  • 只有当用户点击"确定"时,才通过回调函数将新时间传回父组件
  • 这种设计模式称为"临时状态",确保用户操作的可撤销性

3. 如何实现半小时对齐?

aboutToAppear() 中,我们使用了一个简单的算法:

  • 0-14 分钟 → 00 分钟(向下取整)
  • 15-44 分钟 → 30 分钟(向上取整)
  • 45-59 分钟 → 下一个小时的 00 分钟(向上取整)

这样可以确保用户看到的初始值总是半小时对齐的,提供更好的用户体验。

扩展功能

1. 添加快捷时间按钮

你可以在对话框中添加一些快捷按钮,例如"当前时间"、"整点"等:

typescript 复制代码
Row() {
  Button('当前时间')
    .onClick(() => {
      const now = new Date()
      this.tempHour = now.getHours()
      this.tempMinute = now.getMinutes() < 30 ? 0 : 30
    })
  
  Button('整点')
    .onClick(() => {
      this.tempMinute = 0
    })
}

2. 限制时间范围

如果你需要限制用户只能选择某个时间范围(例如今天之内),可以在 onConfirm 回调中添加验证:

typescript 复制代码
onConfirm: (hour: number, minute: number) => {
  const newTime = new Date(this.startTime)
  newTime.setHours(hour)
  newTime.setMinutes(minute)
  
  // 检查是否超过当前时间
  const now = new Date()
  if (newTime > now) {
    // 显示错误提示
    promptAction.showToast({
      message: '不能选择未来的时间',
      duration: 2000
    })
    return
  }
  
  this.startTime = newTime
}

3. 支持更细粒度的时间选择

如果需要支持 15 分钟为单位,只需修改 minutes 数组:

typescript 复制代码
private minutes: string[] = ['00', '15', '30', '45']

然后在 onChange 中相应调整:

typescript 复制代码
onChange((value: string | string[], index: number | number[]) => {
  if (typeof index === 'number') {
    this.tempMinute = index * 15  // 0→0, 1→15, 2→30, 3→45
  }
})

常见问题

Q1: 为什么对话框没有显示?

检查以下几点:

  1. 是否调用了 controller.open()
  2. 对话框的宽度和高度是否设置合理
  3. 是否有其他对话框已经打开(同时只能显示一个对话框)

Q2: 如何修改对话框的背景颜色?

build() 方法的最外层 Column 中修改 backgroundColor 属性:

typescript 复制代码
Column() {
  // ...
}
.backgroundColor('#FFFFFF')  // 使用十六进制颜色
// 或
.backgroundColor($r('app.color.card_background'))  // 使用资源颜色

总结

通过本教程,我们学习了如何在 HarmonyOS 中实现一个自定义时间选择器。核心要点包括:

  1. 使用 @CustomDialog 创建自定义对话框
  2. 使用 CustomDialogController 控制对话框的显示和关闭
  3. 使用 @State 管理对话框内部状态
  4. 使用 TextPicker 组件实现滚动选择
  5. 通过回调函数将选中的值传回父组件
  6. 实现半小时对齐的业务逻辑

这个时间选择器可以直接应用到你的项目中,也可以根据需求进行扩展和定制。希望这篇教程对你有所帮助!

参考资料

相关推荐
早點睡3902 小时前
基础入门 Flutter for OpenHarmony:SnackBar 消息提示组件详解
flutter·harmonyos
果粒蹬i2 小时前
【HarmonyOS】RN of HarmonyOS实战开发项目+Apollo GraphQL客户端
华为·harmonyos·graphql
空白诗2 小时前
基础入门 Flutter for OpenHarmony:BottomSheet 底部面板详解
flutter·harmonyos
柒儿吖2 小时前
基于 lycium 在 OpenHarmony 上交叉编译 utfcpp 完整实践
c++·c#·harmonyos
二流小码农2 小时前
鸿蒙开发:独立开发者的烦恼之icon图标选择
android·ios·harmonyos
前端不太难3 小时前
HarmonyOS PC 多窗口最难的一层
华为·状态模式·harmonyos
木斯佳3 小时前
HarmonyOS 6实战(工程应用篇)—从被动响应到主动治理,如何使用HiAppEvent捕捉应用崩溃信息
华为·harmonyos
果粒蹬i3 小时前
【HarmonyOS】RN of HarmonyOS实战开发项目+TanStack缓存策略
缓存·华为·harmonyos
Swift社区3 小时前
HarmonyOS PC 的核心:任务模型
华为·harmonyos