鸿蒙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')

最终的效果如下:

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

相关推荐
SoraLuna3 小时前
「Mac畅玩鸿蒙与硬件47」UI互动应用篇24 - 虚拟音乐控制台
开发语言·macos·ui·华为·harmonyos
AORO_BEIDOU7 小时前
单北斗+鸿蒙系统+国产芯片,遨游防爆手机自主可控“三保险”
华为·智能手机·harmonyos
博览鸿蒙8 小时前
鸿蒙操作系统(HarmonyOS)的应用开发入门
华为·harmonyos
Damon小智15 小时前
HarmonyOS NEXT 技术实践-基于基础视觉服务的多目标识别
华为·harmonyos
匹马夕阳18 小时前
华为笔记本之糟糕的体验
华为·笔记本电脑
egekm_sefg18 小时前
华为、华三交换机纯Web下如何创关键VLANIF、操作STP参数
网络·华为
岳不谢1 天前
华为DHCP高级配置学习笔记
网络·笔记·网络协议·学习·华为
爱笑的眼睛111 天前
uniapp 极速上手鸿蒙开发
华为·uni-app·harmonyos
K.P1 天前
鸿蒙元服务从0到上架【第三篇】(第二招有捷径)
华为·harmonyos·鸿蒙系统
K.P1 天前
鸿蒙元服务从0到上架【第二篇】
华为·harmonyos·鸿蒙系统