基于OpenHarmony的自定义日历组件实现----详细的自定义日历组件-Bond! 原创
引言
在移动应用开发中,日历组件是一个常见的需求,用户可以通过查看日历来安排自己的行程、任务等。今天,我们将详细介绍一个基于HarmonyOS框架的自定义日历组件的实现细节。该日历组件不仅展示了当前月份的日期,还能根据用户安排的计划在特定日期上进行标记。下面,我们逐步分析这个组件的实现过程。
项目概述
项目主要功能是展示一个自定义的日历,并且可以根据用户输入的计划数据,在日历中标记出计划开始和进行中的日期。日历支持滑动手势来切换到前一个月或后一个月。
项目运行结果
代码结构解析
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];
}
}
写的太好了,日历开发再也不难了
有点意思,可以的
代码封装简洁清晰