鸿蒙Harmony-Next 徒手撸一个日历控件

本文将介绍如何使用鸿蒙Harmony-Next框架实现一个自定义的日历控件。我们将创建一个名为CalendarView的组件(注意,这里不能叫 Calendar因为系统的日历叫这个),它具有以下功能:

  • 显示当前月份的日历
  • 支持选择日期
  • 显示农历日期
  • 可以切换上一月和下一月

组件结构

我们的CalendarView组件主要由以下部分组成:

  1. 月份导航栏
  2. 星期标题
  3. 日期网格

实现代码

typescript:CalendarView.ets 复制代码
@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方法实现月份的切换:

typescript 复制代码
private onMonthChange(increment: number) {
  // ... 月份切换逻辑 ...
}

2. 日期选择

使用onDateSelected方法处理日期选择:

typescript 复制代码
private onDateSelected(day: number) {
  // ... 日期选择逻辑 ...
}

3. 农历日期显示

利用LunarDate类来计算和显示农历日期:

typescript 复制代码
private getLunarDate(day: number): string {
  return LunarDate.solarToLunar(this.currentYear, this.currentMonth + 1, day);
}

4. 日期颜色处理

根据日期状态(过去、当前、选中)设置不同的颜色:

typescript 复制代码
private getDateColor(day: number): string {
  // ... 日期颜色逻辑 ...
}

农历日期转换(LunarDate农历算法实现)

LunarDate类来实现公历到农历的转换,本算法实现主要依赖于预先编码的农历数据和巧妙的位运算 , 优点是计算速度快,代码相对简洁。但缺点是依赖于预先编码的数据,如果需要扩展到1900年之前或2100年之后,就需要额外的数据,及算法上的调整。这个类包含了大量的位运算, 主要方法包括:

  • solarToLunar: 将公历日期转换为农历日期
  • getLunarYearDays: 计算农历年的总天数
  • getLeapMonth: 获取闰月
  • getLeapDays: 获取闰月的天数
  • getLunarMonthDays: 获取农历月的天数

1. 农历数据编码

typescript 复制代码
private static lunarInfo: number[] = [
  0x04bd8, 0x04ae0, 0x0a570, /* ... 更多数据 ... */
];

这个数组包含了从1900年到2100年的农历数据编码。每个数字都是一个16位的二进制数,包含了该年的闰月、大小月等信息。

  • 最后4位: 表示闰月的月份,为0则表示没有闰月。
  • 中间12位: 分别代表12个月,为1表示大月(30天),为0表示小月(29天)。
  • 最高位: 闰月是大月还是小月,仅当存在闰月时有意义。

2. 公历转农历的核心算法

typescript 复制代码
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;
}

主要步骤是:

  1. 计算从1900年1月31日(农历1900年正月初一)到目标日期的总天数。
  2. 逐年递减这个天数,确定农历年份。
  3. 确定该年是否有闰月,以及闰月的位置。
  4. 逐月递减剩余天数,确定农历月份和日期。
  5. 将数字转换为对应的农历文字表示。

3. 辅助方法

获取农历年的总天数

typescript 复制代码
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);
}

这个方法通过位运算来计算一年中每个月的天数,再加上闰月的天数(如果有的话)。

获取闰月信息

typescript 复制代码
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;
}

这些方法用于确定某一年是否有闰月,以及闰月的具体月份和天数。

获取农历月的天数

typescript 复制代码
private static getLunarMonthDays(year: number, month: number): number {
  return (LunarDate.lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29;
}

这个方法通过位运算来确定某个农历月是大月(30天)还是小月(29天)。

完整的代码如下:

typescript 复制代码
@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;
  }
}

使用

typescript 复制代码
 Column() {
      CalendarView()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')

最终的效果如下:

至此我们就徒手撸了一个日历控件的实现了, 各位可以基于这个基础实现,进一步扩展相关的功能,如添加事件标记、自定义主题等,以满足不同应用场景的需求。

相关推荐
小冷爱学习!1 小时前
华为动态路由-OSPF-完全末梢区域
服务器·网络·华为
2501_904447742 小时前
华为发力中端,上半年nova14下半年nova15,大力普及原生鸿蒙
华为·智能手机·django·scikit-learn·pygame
MarkHD2 小时前
第十八天 WebView深度优化指南
华为·harmonyos
塞尔维亚大汉3 小时前
OpenHarmony(鸿蒙南向)——平台驱动开发【MIPI CSI】
harmonyos·领域驱动设计
别说我什么都不会3 小时前
鸿蒙轻内核M核源码分析系列十五 CPU使用率CPUP
操作系统·harmonyos
feiniao86514 小时前
2025年华为手机解锁BL的方法
华为·智能手机
塞尔维亚大汉5 小时前
OpenHarmony(鸿蒙南向)——平台驱动开发【I3C】
harmonyos·领域驱动设计
VVVVWeiYee5 小时前
BGP配置华为——路径优选验证
运维·网络·华为·信息与通信
今阳7 小时前
鸿蒙开发笔记-6-装饰器之@Require装饰器,@Reusable装饰器
android·app·harmonyos
余多多_zZ7 小时前
鸿蒙初学者学习手册(HarmonyOSNext_API14)_组件截图(@ohos.arkui.componentSnapshot (组件截图) )
学习·华为·harmonyos·鸿蒙·鸿蒙系统