1. 从零开始:认识ArkUI日历组件

大家好,我是老张,在移动开发这块摸爬滚打十多年了,从早期的原生开发到后来的跨端框架,再到现在的鸿蒙生态,算是都踩过一遍坑。最近在捣鼓HarmonyOS应用开发,发现很多刚接触ArkUI的朋友,一看到日历组件就有点发怵,觉得这东西配置项多,定制复杂。其实不然,用好一个成熟的第三方日历组件,比如 cjcalendar,能让你在几分钟内就搭建出一个功能强大、样式美观的日历,把精力真正放在业务逻辑上。

那么,cjcalendar 到底是什么?简单说,它是一个为鸿蒙ArkUI框架打造的高性能、高定制化的日历组件库。你可能会问,鸿蒙不是有自带的日历组件吗?没错,但原生的组件往往更偏向基础功能,当你需要实现“酒店订房连续选择”、“课程表多选标记”、“项目进度甘特图”这类稍微复杂一点的场景时,自己从头造轮子就非常痛苦了。cjcalendar 的价值就在于,它把这些复杂场景的通用解决方案都封装好了,你只需要像搭积木一样配置参数、调用方法,就能快速实现。

我最初用它是因为要做一个智能家居的日程管理功能,用户需要设置设备在哪些日期自动执行任务,既要能单选某一天,也要能选择一个日期范围,甚至还要标记出某些特殊的“节能日”。如果自己写,光是处理月份切换、日期计算、选中状态联动这些逻辑,就得花上好几天,还容易出边界条件的bug。用了 cjcalendar 之后,我发现它几乎涵盖了我能想到的所有日历交互模式:常规浏览、单选、多选、范围选择,还支持农历、节假日显示,以及深度到每个单元格的自定义。这就像你买了一个功能齐全的瑞士军刀,而不是自己从磨刀石开始打造一把小刀。

接下来,我就带你从最基础的安装使用开始,一步步解锁这个组件的全部能力,最后我们还会一起动手,把它定制成一个完全符合你项目UI风格的专属日历。相信我,跟着操作一遍,你会觉得日历开发原来可以这么轻松。

2. 快速上手:5分钟搭建你的第一个日历

光说不练假把式,咱们直接动手。首先,你得有一个正在开发中的鸿蒙应用工程。如果还没有,可以用DevEco Studio快速创建一个空的ArkTS工程,这个过程我就不赘述了。

第一步:安装组件。 打开你工程的终端,或者直接在 package.json 文件所在目录执行以下命令:

ohpm install cjcalendar

这里用的是鸿蒙的包管理工具OHPM。安装成功后,你可以在 oh_modules 目录下找到这个包。我遇到过有同学安装失败,通常是网络问题或者镜像源没配置好,可以检查一下OHPM的配置。

第二步:引入并使用。 在你需要用到日历的页面,比如 Index.ets 里,先导入组件,然后像使用一个普通的ArkUI组件一样把它放到页面上。

import { CJCalendar } from 'cjcalendar'

@Entry
@Component
struct Index {
  build() {
    Column() {
      // 最简单的用法,一行代码日历就出来了
      CJCalendar()
    }
    .width('100%')
    .height('100%')
  }
}

保存,运行你的应用。怎么样?一个功能完整的日历应该已经呈现在你眼前了:顶部有年月标题和上下月切换按钮,中间是星期栏,下面是当前月的日期网格,点击“今”按钮还能快速回到今天。整个过程不到五分钟,一个可交互的日历核心就完成了。

但这只是个开始,默认的样式可能和你的应用主题不搭。别急,cjcalendar 提供了大量的属性来调整外观。比如,你觉得标题栏的蓝色太突兀,想改成你们品牌的深灰色,并调整一下字体大小:

CJCalendar({
  titleFontColor: '#2C3E50', // 标题字体颜色改为深灰
  titleFontSize: 20, // 标题字体调大一点
  todayFontColor: '#E74C3C', // “今天”的标记色改为红色
  weekTitleFontColor: '#7F8C8D' // 星期栏字体颜色改为浅灰
})

通过这些简单的颜色、尺寸参数,你就能快速让日历融入你的应用设计语言。属性名都很直观,像 itemFontColor 就是日期数字的颜色,itemFontSize 就是日期数字的大小。我建议你在初次使用时,可以像这样多尝试修改几个属性,看看效果,熟悉一下每个属性控制的是哪一部分的样式。这种即时反馈的学习方式是最快的。

3. 核心功能详解:四种操作模式应对所有场景

基础样式搞定后,我们来聊聊日历的核心——交互。cjcalendar 通过一个叫 optMode 的参数,提供了四种主流的操作模式,这基本覆盖了99%的日历需求。你可以把它理解成日历的四种“状态”。

### 3.1 常规模式 (OptMode.NORMAL)

这是默认模式。在这种模式下,日历就像一个纯粹的“查看器”。用户可以上下翻看月份,点击“今”回到今天,但点击具体的日期单元格不会触发选中状态。这个模式适合用在那些只需要展示日期信息,而不需要用户选择的场景。比如,一个新闻App的归档页面,按月份展示往期文章列表,日历在这里只是起到一个导航和视觉展示的作用。

### 3.2 单选模式 (OptMode.SINGLE)

这是最常用的模式之一,适用于“选择单个日期”的场景。比如,用户选择生日、预约某个具体日期、设置提醒日等。启用这个模式很简单:

CJCalendar({
  optMode: OptMode.SINGLE,
  onDateChange: (selectedDate: CJDateItem) => {
    console.log('用户选择的日期是:', selectedDate.fullYear + '-' + (selectedDate.month + 1) + '-' + selectedDate.date);
    // 在这里,你可以把选中的日期更新到你的业务数据中
  }
})

设置 optMode: OptMode.SINGLE 后,用户点击任意一个有效日期,该日期就会高亮选中(默认是蓝色圆形背景)。同时,你通过 onDateChange 回调函数能立刻拿到用户选择的日期对象 CJDateItem,这个对象里包含了年、月、日、星期、时间戳等完整信息。我做的那个智能家居应用里,设置“单次执行”任务,用的就是这个模式。

### 3.3 范围选择模式 (OptMode.RANGE)

这个模式威力巨大,适用于所有需要选择一段连续日期的场景。典型例子就是酒店预订、机票购买、请假申请(选择开始和结束日期)。配置起来也不复杂:

import { OptMode, SelectedStyle } from 'cjcalendar'

CJCalendar({
  optMode: OptMode.RANGE,
  rangeStyle: SelectedStyle.CLOSE, // 重点:选择范围显示的样式
  onDateChange: (startDate: CJDateItem, endDate: CJDateItem) => {
    if (startDate && endDate) {
      console.log('入住日期:', startDate.date);
      console.log('离店日期:', endDate.date);
      // 计算入住天数等业务逻辑
    }
  }
})

这里有两个关键点:

  1. optMode: 必须设置为 OptMode.RANGE。
  2. rangeStyle: 这个属性决定了选中范围的视觉样式。SelectedStyle.ALONE 是独立风格,开始和结束日期是两个独立的圆形高亮。而 SelectedStyle.CLOSE 是封闭风格,它会把开始和结束日期之间的所有日期连成一个高亮的矩形区域,视觉上更清晰地表示一段连续时间。对于酒店预订这类场景,封闭风格的用户体验明显更好。回调函数会返回两个参数,分别对应开始日期和结束日期。

### 3.4 多选模式 (OptMode.MULTIPLE)

当用户需要选择多个不连续的日期时,就用这个模式。比如,创建一个每周一、三、五健身的打卡计划,或者老师选择需要补课的日期。

CJCalendar({
  optMode: OptMode.MULTIPLE,
  onDateChange: (selectedDates: CJDateItem[]) => {
    console.log('当前已选择日期数量:', selectedDates.length);
    selectedDates.forEach(date => {
      console.log(date.date);
    });
    // 可以将这个日期数组保存起来
  }
})

设置为多选模式后,用户可以点击任意多个日期,每个被点击的日期都会进入选中状态。onDateChange 回调这次返回的是一个数组,里面包含了所有当前被选中的日期对象。你还可以通过 selectedDates 属性,传入一个日期字符串数组(如 ['2024-05-01', '2024-05-04'])来初始化默认选中的日期,这在编辑已有计划时非常有用。

4. 深度定制:让你的日历独一无二

如果只是使用默认样式和四种模式,那还只是发挥了 cjcalendar 一半的功力。它真正强大的地方在于近乎“变态”的自定义能力,可以让你从里到外、从上到下地改造日历的每一个像素。下面我分享几个实战中常用的高级定制技巧。

### 4.1 自定义每一个日期单元格

默认的单元格就是一个数字。但在很多场景下,我们希望在日期下面显示更多信息。比如,一个课程表App需要在对应的日期显示“有课”;一个旅行App想在小格子里显示那天的天气图标。cjcalendar 的 cellLayout 方法就是为你实现这个梦想而生的。

CJCalendar({
  optMode: OptMode.SINGLE,
  // 使用 cellLayout 完全接管每个日期的渲染
  cellLayout: (item: CJDateItem) => {
    this.myCustomCell(item)
  }
})

// 在同一个组件内定义 @Builder 方法
@Builder myCustomCell(item: CJDateItem) {
  Column() {
    // 1. 顶部显示日期数字
    Text(item.date.toString())
      .fontSize(16)
      .fontColor(item.isToday ? '#FFFFFF' : '#333333') // 今天文字颜色特殊处理
      .backgroundColor(item.isToday ? '#007AFF' : Color.Transparent) // 今天加个背景色
      .borderRadius(20)
      .width(30)
      .height(30)
      .textAlign(TextAlign.Center)

    // 2. 根据业务数据,在日期下方显示自定义内容
    if (this.hasEvent(item)) { // 假设 hasEvent 是你的一个业务判断方法
      Text('有课')
        .fontSize(10)
        .fontColor('#FF9500')
        .margin({ top: 2 })
    } else if (this.isHoliday(item)) {
      Image($r('app.media.holiday_icon')) // 假设你有个节日图标
        .width(12)
        .height(12)
        .margin({ top: 2 })
    }
  }
  .width('100%')
  .height(60)
  .justifyContent(FlexAlign.Center)
}

通过 cellLayout,你可以拿到每一个日期单元格的数据 CJDateItem,然后使用ArkUI的语法自由布局。上面这个例子,我们不仅改变了日期的样式,还在下面增加了文字和图标标记。item 对象里有很多有用的属性,比如 isToday(是否是今天)、isSelected(是否被选中)、isPre(是否是上个月的日期)、isNext(是否是下个月的日期),利用这些属性,你可以做出非常精细化的样式区分。

### 4.2 打造专属的顶部导航栏

默认的顶部栏(显示年月和翻页按钮)可能不符合你的产品设计。cjcalendar 允许你通过 cusTopLayout 和 cusTopStateListener 两个方法,完全自定义顶部区域。

@State currentMonthTitle: string = ''

CJCalendar({
  // ... 其他配置
  cusTopLayout: (preMonth: () => void, nextMonth: () => void, backToday: () => void) => {
    this.customTopBar(preMonth, nextMonth, backToday)
  },
  cusTopStateListener: (title: string, hasPre: boolean, hasNext: boolean, showFastToday: boolean) => {
    // 这个回调会告诉你当前的月份标题、是否可以向前/后翻页等信息
    this.currentMonthTitle = title
    // 你可以根据 hasPre/hasNext 来控制自己自定义按钮的禁用状态
  }
})

@Builder customTopBar(preMonth: () => void, nextMonth: () => void, backToday: () => void) {
  Row() {
    // 左侧:自定义返回按钮
    Image($r('app.media.icon_back'))
      .width(24)
      .height(24)
      .onClick(() => {
        // 执行你自己的返回逻辑,或者调用组件提供的 backToday
        backToday()
      })

    // 中间:月份标题,样式完全自定义
    Text(this.currentMonthTitle)
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1A1A1A')
      .layoutWeight(1)
      .textAlign(TextAlign.Center)

    // 右侧:自定义的月份切换按钮组
    Button('<')
      .fontSize(16)
      .onClick(() => { preMonth() }) // 必须调用传入的 preMonth 函数来翻页
    Button('>')
      .fontSize(16)
      .margin({ left: 10 })
      .onClick(() => { nextMonth() }) // 必须调用传入的 nextMonth 函数来翻页
  }
  .padding({ left: 15, right: 15, top: 10, bottom: 10 })
  .width('100%')
  .backgroundColor('#F5F5F5')
}

这样做的好处是,你可以把顶部区域做成任何样子,比如加上品牌Logo、搜索框,或者把翻页按钮设计成更炫酷的样式。关键点在于,当你要触发日历翻页时,必须调用 cusTopLayout 回调函数里提供给你的 preMonth、nextMonth 和 backToday 方法,这样日历内部的数据才会同步更新。

### 4.3 精细化控制选中样式

即使是选中状态,你也可以玩出花来。特别是对于范围选择 (RANGE 模式),你可以通过 selectedBackgroundLayout 方法,自定义开始日期、结束日期以及中间日期的背景样式。

CJCalendar({
  optMode: OptMode.RANGE,
  rangeStyle: SelectedStyle.CLOSE,
  selectedBackgroundLayout: (item: CJDateItem, isStart?: boolean, isEnd?: boolean) => {
    this.customSelectedBg(item, isStart, isEnd)
  }
})

@Builder customSelectedBg(item: CJDateItem, isStart?: boolean, isEnd?: boolean) {
  // isStart 和 isEnd 参数告诉你当前单元格是否是范围的起点或终点
  if (isStart || isEnd) {
    // 起点和终点:画一个实心圆
    Circle({ width: 40, height: 40 })
      .fill(Color.Green)
      .stroke(Color.White)
      .strokeWidth(2)
  } else {
    // 中间日期:画一个浅绿色的矩形背景
    Rect()
      .width('100%')
      .height('80%')
      .fill('#E8F5E9') // 浅绿色
  }
}

这个功能在做一些特色UI时非常有用。比如,你可以把开始日期设计成“出发”图标,结束日期设计成“返回”图标,中间的日期用一条虚线连接,形成一个完整的旅行时间轴效果。这种深度定制能力,能让你的应用在体验细节上脱颖而出。

5. 实战进阶:构建一个完整的酒店预订日历

现在,我们把前面学到的所有知识串起来,做一个贴近真实项目的例子:一个酒店预订页面的日历选择器。这个日历需要:

  1. 只能选择未来日期。
  2. 用范围选择模式,且样式是封闭的连续高亮。
  3. 已预订的日期需要被标记为不可选。
  4. 顶部显示自定义的导航栏,包含价格提示。

首先,我们定义一些模拟数据:

// 模拟已预订的日期(字符串格式 YYYY-MM-DD)
private bookedDates: string[] = ['2024-05-10', '2024-05-11', '2024-05-25']
// 模拟每日价格
private priceMap: Map<string, number> = new Map([
  ['2024-05-15', 399],
  ['2024-05-16', 399],
  ['2024-05-17', 450], // 周末涨价
])

然后,构建我们的日历组件:

CJCalendar({
  optMode: OptMode.RANGE,
  rangeStyle: SelectedStyle.CLOSE,
  // 1. 限制可选日期范围:从明天开始,到明年今天为止
  startDate: new Date(), // 默认就是今天,但我们可以通过item判断是否禁用
  endDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)),
  // 2. 自定义每个单元格,显示价格和预订状态
  cellLayout: (item: CJDateItem) => {
    this.hotelCellLayout(item)
  },
  // 3. 自定义顶部栏
  cusTopLayout: (preMonth, nextMonth, backToday) => {
    this.hotelTopBar(preMonth, nextMonth, backToday)
  },
  cusTopStateListener: (title) => {
    this.currentMonthTitle = title
  },
  // 4. 处理不可点击的日期(已预订)
  disableCellClick: (item: CJDateItem) => {
    // 如果日期已过,或者已被预订,则禁用点击
    let dateStr = `${item.fullYear}-${item.month + 1}-${item.date}`
    let isPast = item.timeStamp < new Date().setHours(0,0,0,0)
    let isBooked = this.bookedDates.includes(dateStr)
    return isPast || isBooked
  },
  // 5. 选择完成后的回调
  onDateChange: (start, end) => {
    if (start && end) {
      // 计算总价等业务逻辑
      this.calculateTotalPrice(start, end)
    }
  }
})

最后,实现最关键的自定义单元格 @Builder 函数:

@Builder hotelCellLayout(item: CJDateItem) {
  let dateStr = `${item.fullYear}-${item.month + 1}-${item.date}`
  let isPast = item.timeStamp < new Date().setHours(0,0,0,0)
  let isBooked = this.bookedDates.includes(dateStr)
  let price = this.priceMap.get(dateStr)

  Column() {
    // 日期数字
    Text(item.date.toString())
      .fontSize(16)
      .fontColor(() => {
        if (isPast || isBooked) return '#CCCCCC' // 过去或已预订,灰色
        if (item.isToday) return '#FFFFFF' // 今天,白色
        return '#333333' // 正常日期,黑色
      })
      .backgroundColor(() => {
        if (item.isToday) return '#007AFF' // 今天蓝色背景
        return Color.Transparent
      })
      .borderRadius(15)
      .width(30)
      .height(30)
      .textAlign(TextAlign.Center)

    // 价格或状态标记
    if (isBooked) {
      Text('已订满')
        .fontSize(9)
        .fontColor('#FFFFFF')
        .backgroundColor('#FF3B30')
        .borderRadius(5)
        .padding({ left: 4, right: 4, top: 1, bottom: 1 })
        .margin({ top: 2 })
    } else if (price && !isPast) {
      Text('¥' + price)
        .fontSize(10)
        .fontColor('#FF9500')
        .margin({ top: 2 })
    }
  }
  .opacity((isPast || isBooked) ? 0.5 : 1) // 不可选的日期整体变透明
  .width('100%')
  .height(70)
  .justifyContent(FlexAlign.Center)
}

通过这样一个完整的例子,你可以看到,cjcalendar 提供的各种API是如何协同工作,来解决一个真实、复杂的业务需求的。从限制日期、自定义显示内容、处理交互禁用,到最终的数据获取,形成了一个清晰的开发链路。在实际项目中,你只需要把模拟数据替换成从网络接口获取的真实数据即可。

更多推荐