使用 UniApp 实现一个精致的日历组件
前言
最近在开发一个约会小程序时,需要实现一个既美观又实用的日历组件。市面上虽然有不少现成的组件库,但都不太符合我们的设计需求。于是,我决定从零开始,基于 UniApp 自己实现一个功能完善、UI精致的日历组件。本文将分享我的实现思路和过程,希望对你有所帮助。
需求分析
首先,让我们明确一下这个日历组件需要满足的需求:
- 日历展示:能够清晰展示年、月、日信息
- 日期选择:支持单选、范围选择功能
- 样式定制:支持自定义主题颜色、特殊日期标记
- 事件标记:能够在特定日期显示事件标记或小红点
- 手势操作:支持左右滑动切换月份
- 农历展示:可选择性展示农历信息
基于以上需求,我们开始设计和编码。
实现思路
1. 数据结构设计
日历组件的核心是对日期数据的管理。我们需要设计一个合理的数据结构来存储和操作日期信息。
javascript
{
year: 2023, // 当前显示的年份
month: 5, // 当前显示的月份(1-12)
day: 15, // 当前选中的日期
weeks: [ // 按周分组的日期数据
[ // 第一周
{
day: 30, // 日期
month: 4, // 所属月份
isCurrentMonth: false, // 是否属于当前月
isToday: false, // 是否是今天
isSelected: false, // 是否被选中
hasEvent: false, // 是否有事件
lunar: '四月初一', // 农历信息
disable: false // 是否禁用
},
// ... 其他日期数据
],
// ... 其他周数据
]
}
2. 核心功能实现
首先,我们需要计算出日历网格中的所有日期数据。这包括当前月的所有日期,以及为了填满网格而需要显示的上个月和下个月的部分日期。
以下是生成日历数据的核心函数:
javascript
/**
* 生成日历数据
* @param {Number} year 年份
* @param {Number} month 月份(1-12)
* @return {Array} 日历数据数组
*/
function generateCalendarData(year, month) {
// 获取当前月第一天是星期几
const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
// 获取当前月的天数
const daysInMonth = new Date(year, month, 0).getDate();
// 获取上个月的天数
const daysInPrevMonth = new Date(year, month - 1, 0).getDate();
// 获取今天的日期信息
const today = new Date();
const isToday = (date) => {
return date.getFullYear() === today.getFullYear()
&& date.getMonth() === today.getMonth()
&& date.getDate() === today.getDate();
};
// 日历数据数组
let days = [];
// 添加上个月的日期
for (let i = 0; i < firstDayOfMonth; i++) {
const prevDay = daysInPrevMonth - firstDayOfMonth + i + 1;
const prevMonth = month - 1 > 0 ? month - 1 : 12;
const prevYear = prevMonth === 12 ? year - 1 : year;
days.push({
day: prevDay,
month: prevMonth,
year: prevYear,
isCurrentMonth: false,
isToday: isToday(new Date(prevYear, prevMonth - 1, prevDay)),
isSelected: false,
hasEvent: false, // 根据实际情况设置
lunar: getLunarDate(prevYear, prevMonth, prevDay), // 获取农历日期
disable: false
});
}
// 添加当前月的日期
for (let i = 1; i <= daysInMonth; i++) {
days.push({
day: i,
month,
year,
isCurrentMonth: true,
isToday: isToday(new Date(year, month - 1, i)),
isSelected: false,
hasEvent: false, // 根据实际情况设置
lunar: getLunarDate(year, month, i), // 获取农历日期
disable: false
});
}
// 添加下个月的日期,补满 6 行
const totalDays = 42; // 6行7列
const remainingDays = totalDays - days.length;
for (let i = 1; i <= remainingDays; i++) {
const nextMonth = month + 1 <= 12 ? month + 1 : 1;
const nextYear = nextMonth === 1 ? year + 1 : year;
days.push({
day: i,
month: nextMonth,
year: nextYear,
isCurrentMonth: false,
isToday: isToday(new Date(nextYear, nextMonth - 1, i)),
isSelected: false,
hasEvent: false, // 根据实际情况设置
lunar: getLunarDate(nextYear, nextMonth, i), // 获取农历日期
disable: false
});
}
// 将日期按周分组
const weeks = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return weeks;
}
3. 组件开发
接下来,让我们使用 UniApp 开发这个日历组件。我们将创建一个独立的组件,以便在不同项目中复用。
vue
<!-- calendar.vue -->
<template>
<view class="calendar">
<!-- 日历头部 -->
<view class="calendar-header">
<view class="calendar-title">
<text>{{ year }}年{{ month }}月</text>
</view>
<view class="calendar-controls">
<view class="prev-month" @click="changeMonth(-1)">
<text class="iconfont icon-left"></text>
</view>
<view class="next-month" @click="changeMonth(1)">
<text class="iconfont icon-right"></text>
</view>
</view>
</view>
<!-- 星期栏 -->
<view class="calendar-weeks">
<view class="week-item" v-for="(item, index) in weekDays" :key="index">
<text :class="{'weekend': index === 0 || index === 6}">{{ item }}</text>
</view>
</view>
<!-- 日期网格 -->
<view class="calendar-days">
<view class="calendar-week" v-for="(week, weekIndex) in weeks" :key="weekIndex">
<view
class="day-item"
v-for="(day, dayIndex) in week"
:key="dayIndex"
:class="{
'current-month': day.isCurrentMonth,
'other-month': !day.isCurrentMonth,
'today': day.isToday,
'selected': day.isSelected,
'disabled': day.disable
}"
@click="selectDay(day)"
>
<view class="day-number">{{ day.day }}</view>
<view class="lunar-date" v-if="showLunar">{{ day.lunar }}</view>
<view class="event-dot" v-if="day.hasEvent"></view>
</view>
</view>
</view>
<!-- 底部操作栏 -->
<view class="calendar-footer" v-if="showFooter">
<view class="btn-today" @click="goToToday">今天</view>
<view class="btn-clear" @click="clearSelection">清除</view>
</view>
</view>
</template>
<script>
export default {
name: 'Calendar',
props: {
// 默认选中日期
value: {
type: [Date, Array],
default: null
},
// 是否多选
multiple: {
type: Boolean,
default: false
},
// 是否显示农历
showLunar: {
type: Boolean,
default: true
},
// 是否显示底部操作栏
showFooter: {
type: Boolean,
default: true
},
// 特殊日期,包含事件的日期
events: {
type: Array,
default: () => []
},
// 主题色
themeColor: {
type: String,
default: '#2979ff'
}
},
data() {
return {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
weeks: [],
weekDays: ['日', '一', '二', '三', '四', '五', '六'],
selectedDays: []
};
},
watch: {
value: {
handler(val) {
this.initSelection(val);
},
immediate: true
},
events: {
handler() {
this.updateCalendar();
}
}
},
created() {
this.updateCalendar();
},
methods: {
// 更新日历数据
updateCalendar() {
this.weeks = this.generateCalendarData(this.year, this.month);
this.markEvents();
this.initSelection(this.value);
},
// 生成日历数据
generateCalendarData(year, month) {
// 实现与上文相同的函数
// ...
},
// 切换月份
changeMonth(offset) {
let newMonth = this.month + offset;
let newYear = this.year;
if (newMonth < 1) {
newMonth = 12;
newYear--;
} else if (newMonth > 12) {
newMonth = 1;
newYear++;
}
this.year = newYear;
this.month = newMonth;
this.updateCalendar();
},
// 选择日期
selectDay(day) {
if (day.disable) return;
if (this.multiple) {
const index = this.selectedDays.findIndex(d =>
d.year === day.year && d.month === day.month && d.day === day.day
);
if (index === -1) {
// 添加选中日期
this.selectedDays.push({
year: day.year,
month: day.month,
day: day.day
});
} else {
// 取消选中
this.selectedDays.splice(index, 1);
}
} else {
// 单选模式
this.selectedDays = [{
year: day.year,
month: day.month,
day: day.day
}];
}
// 更新选中状态
this.updateSelectedStatus();
// 触发事件
this.$emit('input', this.getSelectedDates());
this.$emit('change', this.getSelectedDates());
},
// 初始化选中状态
initSelection(value) {
if (!value) {
this.selectedDays = [];
} else if (Array.isArray(value)) {
this.selectedDays = value.map(date => ({
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate()
}));
} else if (value instanceof Date) {
this.selectedDays = [{
year: value.getFullYear(),
month: value.getMonth() + 1,
day: value.getDate()
}];
}
this.updateSelectedStatus();
},
// 更新选中状态
updateSelectedStatus() {
this.weeks = this.weeks.map(week => {
return week.map(day => {
const isSelected = this.selectedDays.some(d =>
d.year === day.year && d.month === day.month && d.day === day.day
);
return { ...day, isSelected };
});
});
},
// 标记事件
markEvents() {
if (!this.events || !this.events.length) return;
this.weeks = this.weeks.map(week => {
return week.map(day => {
const hasEvent = this.events.some(event => {
const eventDate = new Date(event.date);
return eventDate.getFullYear() === day.year &&
eventDate.getMonth() + 1 === day.month &&
eventDate.getDate() === day.day;
});
return { ...day, hasEvent };
});
});
},
// 返回选中的日期对象
getSelectedDates() {
const dates = this.selectedDays.map(d => {
return new Date(d.year, d.month - 1, d.day);
});
return this.multiple ? dates : dates[0] || null;
},
// 跳转到今天
goToToday() {
const today = new Date();
this.year = today.getFullYear();
this.month = today.getMonth() + 1;
this.updateCalendar();
},
// 清除选择
clearSelection() {
this.selectedDays = [];
this.updateSelectedStatus();
this.$emit('input', this.multiple ? [] : null);
this.$emit('change', this.multiple ? [] : null);
}
}
};
</script>
<style lang="scss">
.calendar {
background-color: #fff;
border-radius: 12rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
padding: 20rpx;
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.calendar-title {
font-size: 32rpx;
font-weight: bold;
}
.calendar-controls {
display: flex;
.prev-month, .next-month {
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
.iconfont {
font-size: 28rpx;
color: #666;
}
}
}
}
.calendar-weeks {
display: flex;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 10rpx;
.week-item {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #999;
.weekend {
color: #ff5252;
}
}
}
.calendar-days {
.calendar-week {
display: flex;
margin-top: 10rpx;
.day-item {
flex: 1;
height: 80rpx;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
border-radius: 8rpx;
&.current-month {
color: #333;
}
&.other-month {
color: #ccc;
}
&.today {
background-color: rgba(41, 121, 255, 0.1);
.day-number {
font-weight: bold;
}
}
&.selected {
background-color: v-bind(themeColor);
color: #fff;
.lunar-date {
color: rgba(255, 255, 255, 0.8);
}
}
&.disabled {
color: #ddd;
cursor: not-allowed;
}
.day-number {
font-size: 28rpx;
}
.lunar-date {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
.event-dot {
position: absolute;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background-color: #ff5252;
bottom: 6rpx;
}
}
}
}
.calendar-footer {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
.btn-today, .btn-clear {
padding: 6rpx 20rpx;
font-size: 24rpx;
border-radius: 4rpx;
margin-left: 20rpx;
}
.btn-today {
background-color: v-bind(themeColor);
color: #fff;
}
.btn-clear {
border: 1px solid #ddd;
color: #666;
}
}
}
</style>
农历计算
对于农历的计算,我们可以使用第三方库,比如 lunar-calendar
,也可以自己实现。以下是一个简化版的农历计算函数:
javascript
function getLunarDate(year, month, day) {
// 实际项目中建议使用成熟的农历库
// 这里只做简单演示
const lunarInfo = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2,
// ... 更多农历数据
];
// 简化版的农历计算,实际项目中请使用完整实现
const lunarDay = ['初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'];
// 模拟计算农历日期,实际使用中请使用准确算法
const dayIndex = (year * 10000 + month * 100 + day) % 30;
return lunarDay[dayIndex];
}
实际使用示例
下面是在实际项目中使用这个日历组件的示例:
vue
<template>
<view class="container">
<view class="header">
<text class="title">我的日程</text>
</view>
<view class="calendar-wrapper">
<Calendar
v-model="selectedDate"
:events="eventList"
themeColor="#42b983"
@change="onDateChange"
/>
</view>
<view class="event-list">
<view class="list-title">
<text>{{ formatDate(selectedDate) }}的日程</text>
</view>
<view class="empty-tip" v-if="!todayEvents.length">
<text>暂无日程安排</text>
</view>
<view class="event-item" v-for="(event, index) in todayEvents" :key="index">
<view class="event-time">{{ event.time }}</view>
<view class="event-content">
<text class="event-title">{{ event.title }}</text>
<text class="event-desc">{{ event.description }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import Calendar from '@/components/calendar/calendar.vue';
export default {
components: {
Calendar
},
data() {
return {
selectedDate: new Date(),
eventList: [
{
date: '2023-05-15',
title: '项目会议',
time: '10:00',
description: '讨论新功能开发计划'
},
{
date: '2023-05-18',
title: '团队建设',
time: '14:00',
description: '外出活动'
},
{
date: '2023-05-22',
title: '产品发布',
time: '09:30',
description: '新版本上线'
}
]
};
},
computed: {
todayEvents() {
if (!this.selectedDate) return [];
const dateStr = this.formatDate(this.selectedDate);
return this.eventList.filter(event => event.date === dateStr);
}
},
methods: {
onDateChange(date) {
console.log('选中日期变化:', date);
},
formatDate(date) {
if (!date) return '';
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
}
};
</script>
<style lang="scss">
.container {
padding: 30rpx;
.header {
margin-bottom: 30rpx;
.title {
font-size: 36rpx;
font-weight: bold;
}
}
.calendar-wrapper {
margin-bottom: 40rpx;
}
.event-list {
background-color: #fff;
border-radius: 12rpx;
padding: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.1);
.list-title {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 20rpx;
color: #333;
}
.empty-tip {
text-align: center;
padding: 40rpx 0;
color: #999;
}
.event-item {
display: flex;
padding: 20rpx 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.event-time {
width: 120rpx;
color: #666;
}
.event-content {
flex: 1;
.event-title {
font-size: 28rpx;
color: #333;
margin-bottom: 6rpx;
}
.event-desc {
font-size: 24rpx;
color: #999;
}
}
}
}
}
</style>
适配鸿蒙系统
随着华为鸿蒙系统的普及,我们也需要考虑在鸿蒙系统上的兼容性。好消息是,UniApp已经开始支持鸿蒙系统的开发。要让我们的日历组件更好地适配鸿蒙系统,可以考虑以下几点:
-
遵循鸿蒙设计规范:鸿蒙系统有自己的设计语言和规范,包括字体、颜色、圆角等。我们可以根据鸿蒙的设计规范调整组件样式。
-
性能优化:鸿蒙系统注重流畅性和低功耗,我们可以减少不必要的渲染和计算,优化日历组件的性能。
-
手势适配:确保日历组件的滑动等手势操作在鸿蒙系统上响应流畅。
-
分辨率适配:鸿蒙设备的分辨率可能有所不同,确保组件在各种分辨率下都能正常显示。
-
权限处理:如果日历组件需要访问系统日历数据,需要适配鸿蒙的权限管理机制。
总结与思考
通过本文,我们从零开始实现了一个功能丰富、外观精致的日历组件。这个组件具有以下特点:
- 灵活的日期选择:支持单选和多选模式
- 农历显示:可选择性显示农历信息
- 事件标记:能够在特定日期显示事件标记
- 自定义主题:支持自定义主题颜色
- 手势操作:支持左右滑动切换月份
当然,这个日历组件还可以进一步优化和扩展,比如:
- 添加周视图、月视图、年视图切换功能
- 支持日程管理,添加、编辑、删除日程
- 增加日程提醒功能
- 支持农历节日、法定节假日标记
- 优化性能,减少不必要的重新渲染
希望这篇文章对你在 UniApp 开发中实现日历组件有所帮助。如果有任何问题或建议,欢迎在评论区交流讨论!
参考资料
- UniApp 官方文档:https://uniapp.dcloud.io/
- Vue.js 文档:https://cn.vuejs.org/
- 农历计算算法:https://github.com/jjonline/calendar.js
- 鸿蒙开发文档:https://developer.harmonyos.com/