✍️作者简介:小北编程(专注于HarmonyOS、Android、Java、Web、TCP/IP等技术方向)
🐳博客主页: 开源中国、稀土掘金、51cto博客、博客园、知乎、简书、慕课网、CSDN 🔔如果文章对您有一定的帮助请👉关注✨、点赞👍、收藏📂、评论💬。
🔥如需转载请参考【转载须知】

🕒 HarmonyOS 自定义日期选择器组件详解

📌 背景与目标

在 HarmonyOS 中,原生的 DatePicker 存在样式限制和扩展性不足的问题。开发者常需要:

  • 隐藏分割线;
  • 自定义滑动选项样式
  • 无法调节各选择项高度等诸多限制

本篇将基于 TextPicker 来实现时间选择器功能,后期还可以做更多的设置,可以只选择月份和日期等。


🧱 整体结构

组件名

功能说明

DatePickDemo

页面入口,包含开始时间与结束时间的选择按钮。

DatePickButton

可展开折叠的时间选择器按钮,显示选中时间。

TimePickerComponent

包含三个 TextPicker 的“年月日”三级联动面板。

TimeUtil.format

工具类,用于日期格式化为字符串。


🖼️ 页面入口组件:DatePickDemo

@ComponentV2
struct DatePickDemo {@Local startDate: Date = new Date()@Local endDate: Date = new Date()build() {Column({ space: 5 }) {DatePickButton({ title: "开始时间:", selectDate: this.startDate })DatePickButton({ title: "结束时间:", selectDate: this.endDate })}.padding({ top: 20 }).height('100%').alignItems(HorizontalAlign.Center)}
}
  • 使用两个 DatePickButton 分别控制开始和结束时间;
  • @Local 用于管理选中日期状态;
  • 布局使用 Column + 垂直间距统一排布。

🔘 日期按钮组件:DatePickButton

该组件集成了标题、显示当前选中日期的按钮、点击展开/收起时间选择器的逻辑。

✅ 显示与点击逻辑

@Param title: string
@Param selectDate: Date
@Local isPickerShow: boolean = false
  • title:按钮左侧标题;
  • selectDate:当前选中的日期;
  • isPickerShow:控制是否展开 TimePickerComponent

⬇️ 展开内容

  • 使用 animateTo 动画切换展开/收起状态;
  • 时间选择器的高度通过 isPickerShow 动态设置;
  • 附加装饰性背景 Rect() 增强视觉反馈。

📌 UI 与布局要点

  • 使用 RelativeContainer 实现内部锚点布局(如 alignRules 控制标题与按钮对齐);
  • 使用 Path 绘制可旋转的小箭头指示图标;
  • 时间显示格式化使用 TimeUtil.format(selectDate, 'YYYY 年 MM 月 DD 日')

🧩 日期选择组件:TimePickerComponent

封装三个 TextPicker 实现 年月日 联动选择器。

📋 数据初始化

@Param startDate: Date = new Date('1970-1-1')
@Param endDate: Date = new Date('2100-12-31')
@Param selectDate: Date = new Date()@Local years: number[] = []
@Local months: number[] = []
@Local Days: number[] = []
  • 构建年份、月份、天数数组;
  • aboutToAppear 初始化数据并设置当前选中项索引。

🔄 联动逻辑

  1. 年份选择:变更后更新当前日期,并刷新天数列表;
  2. 月份选择:变更后更新月份与天数;
  3. 日期选择:直接设置选中日期;

👇 updateDaysInMonth 实现

private updateDaysInMonth(year: number, month: number) {if (month === 2 && this.isLeapYear(year)) {this.Days = Array.from({ length: 29 }, (_, i) => i + 1)} else {const daysInMonth = [31, 28, 31, ..., 31]this.Days = Array.from({ length: daysInMonth[month - 1] }, (_, i) => i + 1)}
}

🌟 闰年判断

private isLeapYear(year: number): boolean {return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0)
}

✅ 样式统一扩展

使用 @Extend(TextPicker) 为三个 Picker 设置统一样式:

@Extend(TextPicker)
function textPickerStyles() {.divider(null).layoutWeight(1).selectedTextStyle({color: Color.Black,font: { weight: FontWeight.Bold }})
}

🛠️ 时间格式化工具类:TimeUtil

用于格式化日期对象,输出为 YYYY 年 MM 月 DD 日 等格式。

TimeUtil.format(date, 'YYYY-MM-DD')

支持格式:

  • 年份:YYYYYY
  • 月份:MMM
  • 日期:DDD
  • 时间:HHmmssSSS

内部逻辑支持:

  • 传入 Date、时间戳或字符串;
  • 提供 useUTC 切换时区处理;
  • 正则匹配并动态替换格式符号。

📊 效果展示

使用上述组件构建的页面,支持如下交互效果:

  • 点击“开始时间”按钮 ➜ 展开时间选择面板;
  • 选择年/月时自动刷新可选日;
  • 点击按钮再次收起;
  • 时间显示立即更新,无需额外回调。

HarmonyOS 自定义日期选择器组件详解_初始化


🧩 可扩展性建议

方向

实现建议

时间范围限制

可在年份构建阶段加入 startDate ~ endDate 校验

显示自定义格式

提供格式化模板参数或回调函数

周视图/时分选择

扩展为 时间轴选择器 或集成小时分钟 picker

绑定双向数据

可结合 @Link@State 提高响应性


整体代码

@ComponentV2
struct DatePickDemo{@Local startDate: Date = new Date()@Local endDate: Date = new Date()build() {Column({ space: 5 }) {DatePickButton({title: "开始时间:",selectDate: this.startDate})DatePickButton({title: "结束时间:",selectDate: this.endDate})}.padding({ top: 20 }).height('100%').width('100%').alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Start)}
}@ComponentV2
export struct DatePickButton {@Param title: string = "开始时间:"@Param selectDate: Date = new Date()@Local isPickerShow: boolean = falsebuild() {RelativeContainer() {Text(this.title).fontSize(20).fontWeight(FontWeight.Bold).id("Title")Button() {Row() {Text(TimeUtil.format(this.selectDate,'YYYY 年 MM 月 DD 日 ')).fontSize(18)Path().width(30).height(30).commands(`M${vp2px(7.5)} ${vp2px(10)} L${vp2px(15)} ${vp2px(20)} L${vp2px(22.5)} ${vp2px(10)} Z`).rotate(this.isPickerShow ? {centerX: "50%",centerY: "50%",angle: 180} : {angle: 0})}.alignItems(VerticalAlign.Center).justifyContent(FlexAlign.SpaceBetween)}.border({color: Color.Black,width: 2,radius: 15}).backgroundColor(Color.White).type(ButtonType.Normal).height(40).margin({ left: 5 }).padding({ left: 15, right: 15 }).alignRules({left: { anchor: "Title", align: HorizontalAlign.End },center: { anchor: "Title", align: VerticalAlign.Center }}).onClick(() => {animateTo({ duration: 100 }, () => {this.isPickerShow = !this.isPickerShow;})}).id("PickerBtn")TimePickerComponent({selectDate: this.selectDate}).height(this.isPickerShow ? 150 : 0).margin({ top: 10 }).alignRules({top: { anchor: "PickerBtn", align: VerticalAlign.Bottom },left: { anchor: "Title", align: HorizontalAlign.Start },right: { anchor: "PickerBtn", align: HorizontalAlign.End }}).id("DatePicker")Rect().width("100%").height(this.isPickerShow ? 35 : 0).radiusWidth(20).fill("#56FFEB").fillOpacity(0.5).stroke(Color.Black).strokeWidth(2).alignRules({middle: { anchor: "DatePicker", align: HorizontalAlign.Center },center: { anchor: "DatePicker", align: VerticalAlign.Center },})}.height(this.isPickerShow ? 200 : 50).width("100%").padding({ left: 15, right: 15 })}
}@ComponentV2
struct TimePickerComponent {@Param startDate: Date = new Date('1970-1-1')@Param endDate: Date = new Date('2100-12-31')@Param selectDate: Date = new Date()@Local years: number[] = []@Local months: number[] = []@Local days: number[] = []@Local yearSelectIndex: number = 0@Local monthSelectIndex: number = 0@Local daySelectIndex: number = 0aboutToAppear(): void {this.years =Array.from<number, number>({ length: this.endDate.getFullYear() - this.startDate.getFullYear() + 1 },(_, k) => this.startDate.getFullYear() + k)this.months = Array.from<number, number>({ length: 12 }, (_, k) => k + 1)this.updateDaysInMonth(this.selectDate.getFullYear(), this.selectDate.getMonth() + 1);this.selectIndexInit();}build() {Row() {// 年份选择TextPicker({ range: this.years.map(x => `${x}年`), selected: this.yearSelectIndex }).onChange((value, index) => {const newYear = this.years[index as number]this.selectDate.setFullYear(newYear)this.updateDaysInMonth(newYear, this.selectDate.getMonth() + 1)}).textPickerStyles()// 月份选择TextPicker({ range: this.months.map(v => `${v}月`), selected: this.monthSelectIndex }).onChange((value, index) => {if (index as number || index == 0) {const newMonth = index as number + 1this.selectDate.setMonth(newMonth - 1)this.updateDaysInMonth(this.selectDate.getFullYear(), newMonth)}}).textPickerStyles()// 日期选择TextPicker({ range: this.days.map(x => `${x}日`), selected: this.daySelectIndex }).onChange((value, index) => {console.info(index.toString())this.selectDate.setDate(index as number + 1)}).textPickerStyles()}.height('100%').width('100%')}/*** 选择索引初始化*/private selectIndexInit() {let yearIndex: number = this.years.findIndex((value: number) => {return this.selectDate.getFullYear() == value});let monthIndex: number = this.months.findIndex((value: number) => {return this.selectDate.getMonth() + 1 == value});let dayIndex: number = this.days.findIndex((value: number) => {return this.selectDate.getDate() == value});this.yearSelectIndex = yearIndex;this.monthSelectIndex = monthIndex;this.daySelectIndex = dayIndex;}private updateDaysInMonth(year: number, month: number) {const daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];if (month === 2 && this.isLeapYear(year)) {this.days = Array.from<number, number>({ length: 29 }, (_, i) => i + 1); // 闰年2月有29天} else {this.days = Array.from<number, number>({ length: daysInMonth[month - 1] }, (_, i) => i + 1);}let dayIndex: number = this.days.findIndex((value: number) => {return this.selectDate.getDate() == value});this.daySelectIndex = dayIndex;}/*** 判断是否是闰年* @param year* @returns*/private isLeapYear(year: number): boolean {return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);}
}@Extend(TextPicker)
function textPickerStyles() {.divider(null).layoutWeight(1).selectedTextStyle({color: Color.Black,font: {weight: FontWeight.Bold}})
}// 工具类
export class TimeUtil {static readonly SECOND: number = 1000static readonly MINUTES: number = 60 * TimeUtil.SECONDstatic readonly HOUR: number = 60 * TimeUtil.MINUTESstatic readonly DAY: number = 24 * TimeUtil.HOURstatic format(value?: number | string | Date, format: string = 'YYYY-MM-DD HH:mm:ss', useUTC: boolean = false): string {try {const date: Date = new Date(value ?? Date.now())if (isNaN(date.getTime())) {throw new Error('Invalid date')}const padZero = (val: number, len: number = 2): string => {return String(val).padStart(len, '0')}const getFullYear = (): number => useUTC ? date.getUTCFullYear() : date.getFullYear()const getMonth = (): number => useUTC ? date.getUTCMonth() : date.getMonth()const getDate = (): number => useUTC ? date.getUTCDate() : date.getDate()const getHours = (): number => useUTC ? date.getUTCHours() : date.getHours()const getMinutes = (): number => useUTC ? date.getUTCMinutes() : date.getMinutes()const getSeconds = (): number => useUTC ? date.getUTCSeconds() : date.getSeconds()const getMilliseconds = (): number => useUTC ? date.getUTCMilliseconds() : date.getMilliseconds()const tokens: Record<string, () => string> = {'YYYY': (): string => padZero(getFullYear()),'YY': (): string => padZero(getFullYear()).slice(2),'MM': (): string => padZero(getMonth() + 1),'M': (): string => String(getMonth() + 1),'DD': (): string => padZero(getDate()),'D': (): string => String(getDate()),'HH': (): string => padZero(getHours()),'H': (): string => String(getHours()),'mm': (): string => padZero(getMinutes()),'m': (): string => String(getMinutes()),'ss': (): string => padZero(getSeconds()),'s': (): string => String(getSeconds()),'SSS': (): string => padZero(getMilliseconds(), 3)}return format.replace(/[(.*?)]|(YYYY|YY|M{1,2}|D{1,2}|H{1,2}|m{1,2}|s{1,2}|SSS)/g,(match: string, escape: string, token: string): string => {return escape || (tokens[token] ? tokens[token]() : token)})} catch (error) {console.error('TimeUtil.format error:', error)return ''}}
}

✅ 总结

通过该方案,我们实现了一个样式自定义、数据联动、动画交互良好的日期选择器。相较于原生 DatePicker,更具灵活性和扩展性,适用于日程安排、时间过滤等场景。

你可以根据项目需求:

  • 拆分封装为通用组件库;
  • 增加日期范围、禁用日期逻辑;
  • 进一步美化样式提升用户体验。

👍 点赞,是我创作的动力!
⭐️ 收藏,是我努力的指引!
✏️ 评论,是我进步的宝藏!
💖 衷心感谢你的阅读以及支持!