本文将介绍如何使用鸿蒙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 + increment
let newYear = this.currentYear
if (newMonth > 11) {
newMonth = 0
newYear++
} else if (newMonth < 0) {
newMonth = 11
newYear--
}
this.currentMonth = newMonth
this.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 = newSelectedDate
this.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')
最终的效果如下:
至此我们就徒手撸了一个日历控件的实现了, 各位可以基于这个基础实现,进一步扩展相关的功能,如添加事件标记、自定义主题等,以满足不同应用场景的需求。