本文将介绍如何使用鸿蒙Harmony-Next框架实现一个自定义的日历控件。我们将创建一个名为CalendarView
的组件(注意,这里不能叫 Calendar因为系统的日历叫这个),它具有以下功能:
- 显示当前月份的日历
- 支持选择日期
- 显示农历日期
- 可以切换上一月和下一月
组件结构
我们的CalendarView
组件主要由以下部分组成:
- 月份导航栏
- 星期标题
- 日期网格
实现代码
@Component
export struct CalendarView {// 组件状态@State selectedDate: Date = new Date()@State isDateSelected: boolean = false@State currentMonth: number = new Date().getMonth()@State currentYear: number = new Date().getFullYear()build() {Column() {// 月份导航栏Row() {// ... 月份切换和显示逻辑 ...}// 星期标题Row() {// ... 星期标题显示逻辑 ...}// 日期网格Grid() {// ... 日期显示和选择逻辑 ...}}}// ... 其他辅助方法 ...
}
关键功能实现
1. 月份切换
通过onMonthChange
方法实现月份的切换:
private onMonthChange(increment: number) {// ... 月份切换逻辑 ...
}
2. 日期选择
使用onDateSelected
方法处理日期选择:
private onDateSelected(day: number) {// ... 日期选择逻辑 ...
}
3. 农历日期显示
利用LunarDate
类来计算和显示农历日期:
private getLunarDate(day: number): string {return LunarDate.solarToLunar(this.currentYear, this.currentMonth + 1, day);
}
4. 日期颜色处理
根据日期状态(过去、当前、选中)设置不同的颜色:
private getDateColor(day: number): string {// ... 日期颜色逻辑 ...
}
农历日期转换(LunarDate农历算法实现)
LunarDate
类来实现公历到农历的转换,本算法实现主要依赖于预先编码的农历数据和巧妙的位运算 , 优点是计算速度快,代码相对简洁。但缺点是依赖于预先编码的数据,如果需要扩展到1900年之前或2100年之后,就需要额外的数据,及算法上的调整。这个类包含了大量的位运算, 主要方法包括:
solarToLunar
: 将公历日期转换为农历日期getLunarYearDays
: 计算农历年的总天数getLeapMonth
: 获取闰月getLeapDays
: 获取闰月的天数getLunarMonthDays
: 获取农历月的天数
1. 农历数据编码
private static lunarInfo: number[] = [0x04bd8, 0x04ae0, 0x0a570, /* ... 更多数据 ... */
];
这个数组包含了从1900年到2100年的农历数据编码。每个数字都是一个16位的二进制数,包含了该年的闰月、大小月等信息。
- 最后4位: 表示闰月的月份,为0则表示没有闰月。
- 中间12位: 分别代表12个月,为1表示大月(30天),为0表示小月(29天)。
- 最高位: 闰月是大月还是小月,仅当存在闰月时有意义。
2. 公历转农历的核心算法
static solarToLunar(year: number, month: number, day: number): string {// ... 前置检查代码 ...let offset = Math.floor((objDate.getTime() - baseDate.getTime()) / 86400000);// 1. 计算农历年for (i = 1900; i < 2101 && offset > 0; i++) {temp = LunarDate.getLunarYearDays(i);offset -= temp;}const lunarYear = i - 1;// 2. 计算闰月leap = LunarDate.getLeapMonth(lunarYear);isLeap = false;// 3. 计算农历月和日for (i = 1; i < 13 && offset > 0; i++) {// ... 月份计算逻辑 ...}const lunarMonth = i;const lunarDay = offset + 1;// 4. 转换为农历文字表示return dayStr === '初一' ? monthStr + "月" : dayStr;
}
主要步骤是:
- 计算从1900年1月31日(农历1900年正月初一)到目标日期的总天数。
- 逐年递减这个天数,确定农历年份。
- 确定该年是否有闰月,以及闰月的位置。
- 逐月递减剩余天数,确定农历月份和日期。
- 将数字转换为对应的农历文字表示。
3. 辅助方法
获取农历年的总天数
private static getLunarYearDays(year: number): number {let i = 0, sum = 348;for (i = 0x8000; i > 0x8; i >>= 1) {sum += (LunarDate.lunarInfo[year - 1900] & i) ? 1 : 0;}return sum + LunarDate.getLeapDays(year);
}
这个方法通过位运算来计算一年中每个月的天数,再加上闰月的天数(如果有的话)。
获取闰月信息
private static getLeapMonth(year: number): number {return LunarDate.lunarInfo[year - 1900] & 0xf;
}private static getLeapDays(year: number): number {if (LunarDate.getLeapMonth(year)) {return (LunarDate.lunarInfo[year - 1900] & 0x10000) ? 30 : 29;}return 0;
}
这些方法用于确定某一年是否有闰月,以及闰月的具体月份和天数。
获取农历月的天数
private static getLunarMonthDays(year: number, month: number): number {return (LunarDate.lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29;
}
这个方法通过位运算来确定某个农历月是大月(30天)还是小月(29天)。
完整的代码如下:
@Component
export struct CalendarView {@State selectedDate: Date = new Date()@State isDateSelected: boolean = false@State currentMonth: number = new Date().getMonth()@State currentYear: number = new Date().getFullYear()build() {Column() {Row() {Text('上一月').fontColor('#165dff').decoration({type: TextDecorationType.Underline,color: '#165dff'}).onClick(() => this.onMonthChange(-1))Text(`${this.currentYear}年${this.currentMonth + 1}月`).fontSize(20).fontWeight(FontWeight.Bold).margin({ left: 15, right: 15 })Text('下一月').fontColor('#165dff').decoration({type: TextDecorationType.Underline,color: '#165dff'}).onClick(() => this.onMonthChange(1))}.width('100%').justifyContent(FlexAlign.Center).margin({ top: 20, bottom: 30 })// 星期标题Row() {ForEach(['日', '一', '二', '三', '四', '五', '六'], (day: string) => {Text(day).width('14%').textAlign(TextAlign.Center).fontSize(18).fontColor('#999999')}, (day: string) => day)}.margin({ bottom: 10 })Grid() {ForEach(this.getDaysInMonth(), (day: number) => {GridItem() {Column() {Text(day.toString()).fontSize(18).fontWeight(this.isSelectedDate(day) ? FontWeight.Bold : FontWeight.Normal).fontColor(this.getDateColor(day))Text(this.getLunarDate(day)).fontSize(12).fontColor(this.getDateColor(day))}.width('100%').height('100%').borderRadius(25).backgroundColor(this.isSelectedDate(day) ? '#007DFF' : Color.Transparent).justifyContent(FlexAlign.Center)}.aspectRatio(1).onClick(() => this.onDateSelected(day))}, (day: number) => day.toString())}.width('100%').columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr').rowsGap(8).columnsGap(8)}.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 }).backgroundColor('#F5F5F5')}private onMonthChange(increment: number) {let newMonth = this.currentMonth + incrementlet newYear = this.currentYearif (newMonth > 11) {newMonth = 0newYear++} else if (newMonth < 0) {newMonth = 11newYear--}this.currentMonth = newMonththis.currentYear = newYear}private onDateSelected(day: number) {const newSelectedDate = new Date(this.currentYear, this.currentMonth, day)if (this.isDateSelected &&this.selectedDate.getDate() === day &&this.selectedDate.getMonth() === this.currentMonth &&this.selectedDate.getFullYear() === this.currentYear) {// 如果点击的是已选中的日期,取消选中this.isDateSelected = false} else {// 否则,选中新的日期this.selectedDate = newSelectedDatethis.isDateSelected = true}}private isSelectedDate(day: number): boolean {return this.isDateSelected &&this.selectedDate.getDate() === day &&this.selectedDate.getMonth() === this.currentMonth &&this.selectedDate.getFullYear() === this.currentYear}private getDaysInMonth(): number[] {const daysInMonth = new Date(this.currentYear, this.currentMonth + 1, 0).getDate()return Array.from<number, number>({ length: daysInMonth }, (_, i) => i + 1)}private getDateColor(day: number): string {const currentDate = new Date(this.currentYear, this.currentMonth, day)const today = new Date()today.setHours(0, 0, 0, 0)if (currentDate < today) {return '#CCCCCC' // 灰色显示过去的日期} else if (this.isSelectedDate(day)) {return '#ffffff' // 选中日期的文字颜色} else {return '#000000' // 未选中日期的文字颜色}}private getLunarDate(day: number): string {return LunarDate.solarToLunar(this.currentYear, this.currentMonth + 1, day);}
}class LunarDate {private static lunarInfo: number[] = [0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977,0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970,0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950,0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557,0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5d0, 0x14573, 0x052d0, 0x0a9a8, 0x0e950, 0x06aa0,0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0,0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b5a0, 0x195a6,0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570,0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0,0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5,0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930,0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530,0x05aa0, 0x076a3, 0x096d0, 0x04bd7, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45,0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0];private static Gan = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"];private static Zhi = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"];private static Animals = ["鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊", "猴", "鸡", "狗", "猪"];private static lunarMonths = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];private static lunarDays = ["初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十","十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十","廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十"];static solarToLunar(year: number, month: number, day: number): string {if (year < 1900 || year > 2100) {return "无效年份";}const baseDate = new Date(1900, 0, 31);const objDate = new Date(year, month - 1, day);let offset = Math.floor((objDate.getTime() - baseDate.getTime()) / 86400000);let i: number, leap = 0, temp = 0;for (i = 1900; i < 2101 && offset > 0; i++) {temp = LunarDate.getLunarYearDays(i);offset -= temp;}if (offset < 0) {offset += temp;i--;}const lunarYear = i;leap = LunarDate.getLeapMonth(i);let isLeap = false;for (i = 1; i < 13 && offset > 0; i++) {if (leap > 0 && i === (leap + 1) && isLeap === false) {--i;isLeap = true;temp = LunarDate.getLeapDays(lunarYear);} else {temp = LunarDate.getLunarMonthDays(lunarYear, i);}if (isLeap === true && i === (leap + 1)) {isLeap = false;}offset -= temp;}if (offset === 0 && leap > 0 && i === leap + 1) {if (isLeap) {isLeap = false;} else {isLeap = true;--i;}}if (offset < 0) {offset += temp;--i;}const lunarMonth = i;const lunarDay = offset + 1;const monthStr = (isLeap ? "闰" : "") + LunarDate.lunarMonths[lunarMonth - 1];const dayStr = LunarDate.lunarDays[lunarDay - 1];return dayStr === '初一' ? monthStr + "月" : dayStr;}private static getLunarYearDays(year: number): number {let i = 0, sum = 348;for (i = 0x8000; i > 0x8; i >>= 1) {sum += (LunarDate.lunarInfo[year - 1900] & i) ? 1 : 0;}return sum + LunarDate.getLeapDays(year);}private static getLeapMonth(year: number): number {return LunarDate.lunarInfo[year - 1900] & 0xf;}private static getLeapDays(year: number): number {if (LunarDate.getLeapMonth(year)) {return (LunarDate.lunarInfo[year - 1900] & 0x10000) ? 30 : 29;}return 0;}private static getLunarMonthDays(year: number, month: number): number {return (LunarDate.lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29;}
}
使用
Column() {CalendarView()}.width('100%').height('100%').backgroundColor('#F5F5F5')
最终的效果如下:
至此我们就徒手撸了一个日历控件的实现了, 各位可以基于这个基础实现,进一步扩展相关的功能,如添加事件标记、自定义主题等,以满足不同应用场景的需求。