基于OpenHarmony的自定义日历组件实现----详细的自定义日历组件-Bond! 原创

bond_heloworld
发布于 2025-2-11 09:08
浏览
2收藏

引言

在移动应用开发中,日历组件是一个常见的需求,用户可以通过查看日历来安排自己的行程、任务等。今天,我们将详细介绍一个基于HarmonyOS框架的自定义日历组件的实现细节。该日历组件不仅展示了当前月份的日期,还能根据用户安排的计划在特定日期上进行标记。下面,我们逐步分析这个组件的实现过程。

项目概述

项目主要功能是展示一个自定义的日历,并且可以根据用户输入的计划数据,在日历中标记出计划开始和进行中的日期。日历支持滑动手势来切换到前一个月或后一个月。

项目运行结果

基于OpenHarmony的自定义日历组件实现----详细的自定义日历组件-Bond!-鸿蒙开发者社区

代码结构解析

1. 接口定义

interface Riqi{
  startYear:number;
  startMonth:number;
  startDay:number;
  endYear:number;
  endMonth:number;
  endDay:number;
  jihuaName:string;
}

首先,我们定义了一个名为Riqi的接口,用来描述每个计划的开始年、开始月、开始日、结束年、结束月、结束日以及计划名称。

2. 组件定义

@Entry
@Component
struct DataPage {
  @State jihua:Array<Riqi>=[
    {startYear:2025,startMonth:2,startDay:2,endYear:2025,endMonth:2,endDay:5,jihuaName:"洛阳之旅"},
    {startYear:2025,startMonth:2,startDay:11,endYear:2025,endMonth:2,endDay:25,jihuaName:"南京之旅"}
  ]
  @State selectedDate: Date = now;
  @State week:Array<string>=["日","一","二","三","四","五","六"]
  @State year:number=now.getFullYear();
  @State month:number=now.getMonth()+1;
  @State day:number=now.getDay();
  @State riqi:Array<string>=tian(this.year,this.month, findday(this.year,this.month,this.day))
  @State riqi_qian:Array<string>=tian(this.year,this.month-1, findday(this.year,this.month-1,this.day))
  @State riqi_hou:Array<string>=tian(this.year,this.month+1, findday(this.year,this.month+1,this.day))

  // ... methods ...
}

我们使用@Entry和@Component装饰器定义了一个名为DataPage的组件。组件内部使用@State装饰器定义了一系列状态变量,包括计划列表jihua、选中的日期selectedDate、一周的天数week、当前年份year、当前月份month、当前星期几day以及当前月份的日历数据riqi。

3. 判断日期是否在计划中的方法

  private isDateInPlan(date: number): boolean {
    return this.jihua.some(plan => 
      this.year === plan.startYear && 
      this.month === plan.startMonth && 
      (plan.startDay === date || plan.endDay === date)
    );
  }

  private isPlanStartDate(date: number): boolean {
    return this.jihua.some(plan => 
      this.year === plan.startYear && 
      this.month === plan.startMonth && 
      plan.startDay === date
    );
  }

  private isDateInPlanRange(date: number): boolean {
    return this.jihua.some(plan => 
      this.year === plan.startYear && 
      this.month === plan.startMonth && 
      plan.startDay < date && 
      plan.endDay > date
    );
  }

  private getPlanName(date: number): string {
    const plan = this.jihua.find(p => 
      this.year === p.startYear && 
      this.month === p.startMonth && 
      p.startDay === date
    );
    return plan ? plan.jihuaName : '';
  }

这些私有方法用于判断某个日期是否在用户定义的计划中,以及该日期是计划的开始日期还是在计划的进行期间。这些方法为后续的日历渲染提供了必要的逻辑条件。

4. 日历标题与日期选择器

      Text(`${this.year}年${this.month}月`)
        .fontSize(24)
        .fontWeight(500)
        .onClick(()=>{
          DatePickerDialog.show({
            start: new Date("2000-1-1"),
            end: new Date("2100-12-31"),
            selected: this.selectedDate,
            showTime:false,
            useMilitaryTime:false,
            disappearTextStyle: {color: Color.Pink, font: {size: '22fp', weight: FontWeight.Bold}},
            textStyle: {color: '#ff00ff00', font: {size: '18fp', weight: FontWeight.Normal}},
            selectedTextStyle: {color: '#ff182431', font: {size: '14fp', weight: FontWeight.Regular}},
            onDateAccept: (value: Date) => {
              this.selectedDate = value
              this.year=value.getFullYear();
              this.month=value.getMonth()+1;
              this.day=value.getDay();
              this.riqi=tian(this.selectedDate.getFullYear(),this.selectedDate.getMonth()+1, findday(this.selectedDate.getFullYear(),this.selectedDate.getMonth()+1,this.selectedDate.getDay()))
            },
            onCancel: () => {},
            onDateChange: () => {},
            onDidAppear: () => {},
            onDidDisappear: () => {},
            onWillAppear: () => {},
            onWillDisappear: () => {}
          })
        })

这段代码定义了一个文本按钮,用于显示当前日期的年份和月份,并且当用户点击该按钮时,会弹出一个日期选择器对话框。用户可以在对话框中选择日期,选择完成后,该组件会更新当前选中的日期并刷新日历显示。

5. 星期导航栏

      ForEach(this.week,(item:string)=>{
        GridItem() {
          Button({ type: ButtonType.Normal }) {
            Text(`${item}`)
              .fontSize(16)
              .fontColor("#000000")
          }.width('90%')
          .height('90%')
          .borderRadius(0)
          .backgroundColor("rgba(245, 245, 245, 1)")
        }
      })//循环渲染星期导航栏

这段代码用于渲染星期导航栏,通过ForEach循环遍历week数组,为每个星期几创建一个按钮,并设置按钮的样式。

6. 日期渲染

      ForEach(this.riqi, (item:string, index:number) => {
        GridItem() {
          Column(){
            // 判断是否为当前月份的日期
            if (index >= findday(this.year, this.month, this.day) && 
                index < findday(this.year, this.month, this.day) + getDaysInMonth(this.year, this.month)) {
              
              if(this.isDateInPlan(Number(item))) {
                // 标记计划开始和进行中的日期
              } else {
                // 渲染普通日期
              }
            } else {
              // 渲染非当前月份的日期
            }
          }
        }
      })

这段代码通过ForEach循环遍历riqi数组,渲染出当前月份的日历。每个日期都会被包裹在一个按钮中,并根据不同的条件设置不同的样式。如果日期在用户的计划中,会用特定的颜色标记;如果不是当前月份的日期,会以浅灰色显示。

7. 滑动手势支持

    .gesture(
      SwipeGesture({
        direction: SwipeDirection.Horizontal,
        fingers: 1,
        speed: 50
      })
        .onAction(event => {
          if(event.angle > 0){
            // 从右往左滑
            this.month++;
            if(this.month > 12){
              this.month=1;
              this.year++;
            }
            this.riqi=tian(this.year,this.month, findday(this.year,this.month,this.day))
          }else{
            // 从左往右滑
            this.month--;
            if(this.month < 1){
              this.month=12;
              this.year--;
            }
            this.riqi=tian(this.year,this.month, findday(this.year,this.month,this.day))
          }
        })
    )

为了支持用户滑动切换月份的功能,我们给整个日历组件添加了一个滑动手势。当检测到左滑(即event.angle < 0)时,月份减一,如果月份小于1,则将月份设置为12并年份减一;当检测到右滑(即event.angle > 0)时,月份加一,如果月份大于12,则将月份设置为1并年份加一。每次月份切换后,都会重新计算并渲染该月份的日历。

辅助函数解析

findday函数

function findday(y:number,m:number,d:number) {
  // 计算指定日期是该月第几天
}

findday函数用于计算指定的日期是该月的第几天。它首先计算出从2000年1月1日到指定日期之前的总天数,然后通过计算得出该日期在当月的星期几,从而确定日历显示时前面需要空出几个格子。

tian函数

function tian(y: number, m: number, kong: number): Array<string> {
  // 根据年份、月份和前面空出的格子数,生成一个包含42个格子的日历数组
}

tian函数根据年份、月份以及前面需要空出的格子数来生成一个包含42个元素的数组,这个数组代表了当前月份的日历布局。数组中的元素可能是上个月的日期、本月的日期或者下个月的日期。

getDaysInMonth函数

function getDaysInMonth(year: number, month: number): number {
  // 返回指定月份的天数
}

这个简单的函数用来返回指定年份和月份的天数。它根据年份是否为闰年来判断二月的天数是28天还是29天。

总结

通过以上详细的代码解析,我们可以看到这个自定义日历组件的实现采用了多种HarmonyOS提供的布局和交互组件,包括Grid、GridItem、ForEach、DatePickerDialog等。日历不仅能够展示当前月份的日期,还能根据用户定义的计划来标记特定日期,提供了一个不错的用户交互体验。此外,组件还支持通过滑动手势来切换月份,使得用户操作更加便捷。

详细代码展示

const now = new Date();

interface Riqi{
  startYear:number;
  startMonth:number;
  startDay:number;
  endYear:number;
  endMonth:number;
  endDay:number;
  jihuaName:string;
}

@Entry
@Component
struct DataPage {
  @State jihua:Array<Riqi>=[
    {startYear:2025,startMonth:2,startDay:2,endYear:2025,endMonth:2,endDay:5,jihuaName:"洛阳之旅"},
    {startYear:2025,startMonth:2,startDay:11,endYear:2025,endMonth:2,endDay:25,jihuaName:"南京之旅"}
  ]
  @State selectedDate: Date = now;
  @State week:Array<string>=["日","一","二","三","四","五","六"]
  @State year:number=now.getFullYear();
  @State month:number=now.getMonth()+1;
  @State day:number=now.getDay();
  @State riqi:Array<string>=tian(this.year,this.month, findday(this.year,this.month,this.day))
  @State riqi_qian:Array<string>=tian(this.year,this.month-1, findday(this.year,this.month-1,this.day))
  @State riqi_hou:Array<string>=tian(this.year,this.month+1, findday(this.year,this.month+1,this.day))

  private isDateInPlan(date: number): boolean {
    return this.jihua.some(plan => 
      this.year === plan.startYear && 
      this.month === plan.startMonth && 
      (plan.startDay === date || plan.endDay === date)
    );
  }

  private isPlanStartDate(date: number): boolean {
    return this.jihua.some(plan => 
      this.year === plan.startYear && 
      this.month === plan.startMonth && 
      plan.startDay === date
    );
  }

  private isDateInPlanRange(date: number): boolean {
    return this.jihua.some(plan => 
      this.year === plan.startYear && 
      this.month === plan.startMonth && 
      plan.startDay < date && 
      plan.endDay > date
    );
  }

  private getPlanName(date: number): string {
    const plan = this.jihua.find(p => 
      this.year === p.startYear && 
      this.month === p.startMonth && 
      p.startDay === date
    );
    return plan ? plan.jihuaName : '';
  }

  //以2024.12的日历作为初始页面
  //对各个变量进行初始化
  build() {
    Grid() {
      GridItem(){
        Row(){
          Text(`${this.year}年${this.month}月`)
            .fontSize(24)
            .fontWeight(500)
            .onClick(()=>{
              DatePickerDialog.show({ // 建议使用 this.getUIContext().showDatePickerDialog()接口
                start: new Date("2000-1-1"),
                end: new Date("2100-12-31"),
                selected: this.selectedDate,
                showTime:false,
                useMilitaryTime:false,
                disappearTextStyle: {color: Color.Pink, font: {size: '22fp', weight: FontWeight.Bold}},
                textStyle: {color: '#ff00ff00', font: {size: '18fp', weight: FontWeight.Normal}},
                selectedTextStyle: {color: '#ff182431', font: {size: '14fp', weight: FontWeight.Regular}},
                onDateAccept: (value: Date) => {
                  // 通过Date的setFullYear方法设置按下确定按钮时的日期,这样当弹窗再次弹出时显示选中的是上一次确定的日期
                  this.selectedDate = value
                  console.info("DatePickerDialog:onDateAccept()" + value.toString())
                  this.year=value.getFullYear();
                  this.month=value.getMonth()+1;
                  this.day=value.getDay();
                  this.riqi=tian(this.selectedDate.getFullYear(),this.selectedDate.getMonth()+1, findday(this.selectedDate.getFullYear(),this.selectedDate.getMonth()+1,this.selectedDate.getDay()))
                },
                onCancel: () => {
                  console.info("DatePickerDialog:onCancel()")
                },
                onDateChange: (value: Date) => {
                  console.info("DatePickerDialog:onDateChange()" + value.toString())
                },
                onDidAppear: () => {
                  console.info("DatePickerDialog:onDidAppear()")
                },
                onDidDisappear: () => {
                  console.info("DatePickerDialog:onDidDisappear()")
                },
                onWillAppear: () => {
                  console.info("DatePickerDialog:onWillAppear()")
                },
                onWillDisappear: () => {
                  console.info("DatePickerDialog:onWillDisappear()")
                }
              })
            })
          Image($r('app.media.sanjiao')).width(13.56).height(9.81).margin({left:5})
        }
        .width('90%')
        .height('100%')

        //根据用户输入的年月更改页面标题
      }.columnStart(1)
      .columnEnd(3)

      GridItem() {
        Column() {
          Blank()
        }
      }.columnStart(4)
      .columnEnd(7)

      //跳转前一个或下一个月
      ForEach(this.week,(item:string)=>{
        GridItem() {
          Button({ type: ButtonType.Normal }) {
            Text(`${item}`)
              .fontSize(16)
              .fontColor("#000000")
          }.width('90%')
          .height('90%')
          .borderRadius(0)
          .backgroundColor("rgba(245, 245, 245, 1)")
        }
      })//循环渲染星期导航栏
      // ForEach(this.riqi_qian, (item:string) => {})
      ForEach(this.riqi, (item:string, index:number) => {
        GridItem() {
          Column(){
            // 判断是否为当前月份的日期
            if (index >= findday(this.year, this.month, this.day) && 
                index < findday(this.year, this.month, this.day) + getDaysInMonth(this.year, this.month)) {
              
              if(this.isDateInPlan(Number(item))) {
                // if(this.isPlanStartDate(Number(item)))
                Row() {
                  Button({type: ButtonType.Normal}) {
                    Text(`${item}`)
                      .fontSize(20)
                  }.width(32)
                  .height(32)
                  .borderRadius(5)
                  .backgroundColor("rgba(137, 186, 32, 1)")
                  // .margin({left:this.isPlanStartDate(Number(item))?0:'26%',right:this.isPlanStartDate(Number(item))?'26%':0})
                }.width('84%')
                .height(32)
                .justifyContent(this.isPlanStartDate(Number(item))?FlexAlign.Start:FlexAlign.End)
                .backgroundColor("rgba(137, 186, 32, 0.2)")
                .margin({left:this.isPlanStartDate(Number(item))?'16%':0,right:this.isPlanStartDate(Number(item))?0:'16%'})
                .borderRadius({topLeft:this.isPlanStartDate(Number(item))?5:0,topRight:this.isPlanStartDate(Number(item))?0:5,bottomLeft:this.isPlanStartDate(Number(item))?5:0,bottomRight:this.isPlanStartDate(Number(item))?0:5})

                if(this.isPlanStartDate(Number(item))) {
                  Column() {
                    Button({type: ButtonType.Normal}) {
                      // Image($r('app.media.startIcon'))
                      // Text(this.getPlanName(Number(item)))
                      //   .fontSize(12)
                      //   .fontColor("#ff034980")
                      Row(){
                        Image($r('app.media.qizi'))
                          .height(13)
                          .width(14.06)
                        Text(this.getPlanName(Number(item)))
                          .fontSize(16)
                          .fontColor("rgba(133, 133, 133, 1)")
                      }.justifyContent(FlexAlign.SpaceAround).width('80%')
                    }.width(104).height(29).margin({left:70})
                    .backgroundColor("rgba(237, 237, 237, 1)")
                    .borderRadius(5)
                  }
                  .height('60%')
                  .backgroundColor("rgba(245, 245, 245, 1)")
                  .margin({top:'20%'})
                  // .justifyContent(FlexAlign.Center)
                }
              } else if(this.isDateInPlanRange(Number(item))) {
                Button({type: ButtonType.Normal}) {
                  Text(`${item}`)
                    .fontSize(20)
                }.width(index%7==0||(index+1)%7==0?'84%':'100%')
                .height(32)
                .borderRadius({
                   topLeft:index%7==0?5:0,
                   topRight:(index+1)%7==0?5:0,
                   bottomLeft:index%7==0?5:0,
                   bottomRight:(index+1)%7==0?5:0
                })
                .backgroundColor("rgba(137, 186, 32, 0.2)")
                .margin({left:(index+1)%7==0?0:'16%',right:index%7==0?0:'16%'})
              } else {
                Button({type: ButtonType.Normal}) {
                  Text(`${item}`)
                    .fontSize(20)
                    .fontColor('#000000')
                }.width('100%')
                .height(32)
                .borderRadius(0)
                .backgroundColor("rgba(245, 245, 245, 1)")
                .margin({left:(index+1)%7==0?0:'16%',right:index%7==0?0:'16%'})

              }
            } else {
              Button({type: ButtonType.Normal}) {
                Text(`${item}`)
                  .fontSize(20)
                  .fontColor('#999999')
              }.width('100%')
              .height(32)
              .borderRadius(0)
              .backgroundColor("rgba(245, 245, 245, 1)")
              .margin({left:(index+1)%7==0?0:'16%',right:index%7==0?0:'16%'})
            }
          }.width('100%')
          .height('100%')
          .justifyContent(FlexAlign.SpaceBetween)
        }
      })//渲染日期


    }
    .backgroundColor("rgba(245, 245, 245, 1)")
    .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')
    .rowsTemplate(this.riqi.length>35?'1fr 1fr 2fr 2fr 2fr 2fr 2fr 2fr 2fr 1fr':'1fr 1fr 2fr 2fr 2fr 2fr 2fr 2fr 1fr' )
    .gesture(
      SwipeGesture({
        //触发滑动手势的滑动方向为水平方向。
        direction: SwipeDirection.Horizontal,
        fingers: 1,
        speed: 50
      })
        //滑动手势识别成功回调。
        .onAction(event => {
          //angle是负的就是从左往右滑
          if(event.angle > 0){
            //从右往左滑
            if(this.month==12){
              this.month=1;
              this.year++;
            }else{
              this.month++;
            }
            this.riqi=tian(this.year,this.month, findday(this.year,this.month,this.day))

          }else{
            //从左往右滑
            if(this.month==1){
              this.month=12;
              this.year--;
            }else{
              this.month--;
            }
            this.riqi=tian(this.year,this.month, findday(this.year,this.month,this.day))
          }
        })
    )
    //根据月份天数判断需要几行
  }
}

function findday(y:number,m:number,d:number) {
  let run: Array<number> = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  let feirun: Array<number> = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  let sum: number = 0;
  let nian: number = 2000;
  let yue: number = 1;
  let ri: number = 1;
  let jujin: number = 0;
  for (let j = nian; j < y; j++) {
    //计算前几年有多少天,需区分是否为闰年
    sum = 0;
    if ((j % 400 == 0) || (j % 4 == 0 && j % 100 != 0)) {
      sum += 366;
    } else {
      sum += 365;
    }
    jujin += sum;
  }
  sum = 0; //计算当年有多少天
  if ((y % 400 == 0) || (y % 4 == 0 && y % 100 != 0)) {
    for (let i = 0; i < m-1; i++) {
      sum += run[i];
    }
  } else {
    for (let i = 0; i < m-1; i++) {
      sum += feirun[i];
    }
  }
  jujin += sum;
  jujin = (jujin+6) % 7;
  //计算出要显示的日历前面需要空几格
  return jujin;
}

function tian(y: number, m: number, kong: number): Array<string> {
  let day: Array<string> = new Array(42); // 总共42个格子
  let d: number;
  let run: Array<number> = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  let feirun: Array<number> = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

  // 确定本月天数
  if ((y % 400 == 0) || (y % 4 == 0 && y % 100 != 0)) {
    d = run[m - 1];
  } else {
    d = feirun[m - 1];
  }

  // 计算上个月的天数
  let lastMonth: number = (m - 2 + 12) % 12;
  let lastMonthDays: number = (y % 400 == 0 || (y % 4 == 0 && y % 100 != 0)) ? run[lastMonth] : feirun[lastMonth];

  // 填充前面的空格为上个月的最后几天
  for (let i = 0; i < kong; i++) {
    day[i] = (lastMonthDays - kong + i + 1).toString(); // 依次填充
  }

  // 填充本月的日期
  for (let i = kong; i < d + kong; i++) {
    day[i] = (i - kong + 1).toString();
  }

  // 填充后面的空格为下个月的前几天
  for (let i = d + kong; i < 42; i++) {
    day[i] = (i - d - kong + 1).toString();
  }

  return day;
}

function padZero(arg0: number): number {
  throw new Error('Function not implemented.');
}

// 添加一个新的辅助函数来获取指定月份的天数
function getDaysInMonth(year: number, month: number): number {
  let run: Array<number> = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  let feirun: Array<number> = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  
  if ((year % 400 == 0) || (year % 4 == 0 && year % 100 != 0)) {
    return run[month - 1];
  } else {
    return feirun[month - 1];
  }
}

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
已于2025-2-11 10:19:48修改
6
收藏 2
回复
举报
3条回复
按时间正序
/
按时间倒序
Crips
Crips

写的太好了,日历开发再也不难了

1
回复
2025-2-11 11:41:33
在敲键盘的小鱼干很饥饿
在敲键盘的小鱼干很饥饿

有点意思,可以的


回复
2025-2-12 20:05:22
言程序plus
言程序plus

代码封装简洁清晰

1
回复
2025-2-20 16:06:37
回复
    相关推荐