手把手教你Uniapp开发一个日历小组件

1,199 阅读2分钟

1.新建calendars.vue组件,该组件为日历组件主要内容,负责日期切换,日期展示。

日历组件的核心就是计算当前页面应该显示的日期,由于该数据的计算一定是动态的,所以我们使用计算属性,

  • 首先获取并传入当前月份,currentFirstDay获取当前月份第一天的时间标准模式,但是由于日历表一般是周日、周一、这种展示模式(如下图),也就是说我们需要计算本页面的真正的开始时间,week获取当前月份第一天是周几,用当前月份第一天的时间减去一周第几天的时间,获得开始日期。

1655623688184.jpg

        let {
            time: {
		year,
		month
            },
            month_data // 传入的特殊标记日期 
	} = this;
	let currentFirstDay = getDate(year, month, 1);
	let week = currentFirstDay.getDay();
	let startDay = currentFirstDay - week * 60 * 60 * 1000 * 24;
  • 我们计算完开始时间后,需要根据开始时间,逐步推算本页面需要展示的所有日期,一般日历组件都是7*6的格式,因此用循环方式,根据初始日期+i * 60 * 60 * 1000 * 24完善展示时间数组。month_data是我们外部传入的特殊日期,比如需要标记某个日期有任务,那么我们根据日期匹配,插入时间展示数组中。
let arr = [];
for (let i = 0; i < 42; i++) {
	let day = new Date(startDay + i * 60 * 60 * 1000 * 24);
	let {
		year: dayY,
		month: dayM,
		day: dayD
	} = getYearMonthDay(day);
	let data = {};
	for (let item of month_data) {
		let dateString = item.date;
		let dateArr = dateString.indexOf('-') !== -1 ?
			dateString.split('-') :
			dateString.indexOf('/') !== -1 ?
			dateString.split('/') :
			[];
		if (dateArr.length === 3 &&
			Number(dateArr[0]) === Number(dayY) &&
			Number(dateArr[1]) === (Number(dayM) + 1) &&
			Number(dateArr[2]) === Number(dayD)) {
			data = item
		}
	}
	let obj = {
		day,
		data
	}
	arr.push(obj)
}
  • 页面展示

根据visibleDays返回的数组进行渲染,并使用动态class进行样式处理,如notCurrentMonth判断是否是当前月份,否则置灰,同时也可以根据我们在计算展示日期时标记的特殊日期进行特殊渲染。

<view class="flex-wrap day-all-box" >
 <template v-for="item in visibleDays" :Key="item.day">
  <view class="day-box flex-column" :class="[{istodaypadding: !isToday(item.day)}]">
    <div class='day-back' 
    :class="[
             {istodayback: isToday(item.day)}, // 是否是今天
             {isselected: isClick(item.day)}, // 是否是点击选中的日期
           ]" 
    @click="clickDate(item.day,item.data)"
    >
        <text class="day"   
        :class="[
		{isselectedtext: isClick(item.day)}, 
                {notCurrentMonth: !isCurrentMonth(item.day)},
                {noScore:hasScore(item.data.dot,item.data.status)},
		{istodaytext: isToday(item.day)}
		]">{{item.day | dayFilter}}</text>
	</div>
	<!-- 是否展示文字 -->
	<template v-if="showText">
            <text v-if="isCurrentMonth(item.day)" class="day-text" :style="{color: textColor}">
            {{item.data.value || ''}}
            </text>
        </template>	
	<template v-if="showDot">
            <text v-if="isCurrentMonth(item.day) && item.data.dot && (item.data.status===2)" 
            class="day-dot">
            </text>
	</template>
    </view>
</template>
</view>
  • 接下来 我们近一步完善日子里逐渐,包括加入日期选择器,以及左右滑动切换月份功能,使用picker进行日期选择。
    <view @click="prevMonth">
    <i class='iconfont lefticon icon-zihoudianjisanjiaoxuanze1x'></i>
    </view>
    <view class='time-picker'>
	<picker mode="date" fields="month" @change="monthChange" :start="startYear" :end="endYear">
		<text class="bold">{{time.year}}年{{time.month + 1}}月</text>
        </picker>
    </view>
  • 同时在日历根节点根据touchstart、touchend判断滑动方向,从而决定切换上一个月份或者下一个月份,同时要注意计算左右滑动距离,从而决定是否切换
<view class="content" @touchstart="start" @touchend="end">
	//滑动监听
	start(e){  
            this.startData.clientX=e.changedTouches[0].clientX;             
            this.startData.clientY=e.changedTouches[0].clientY;
	},
	end(e){
            const subX=e.changedTouches[0].clientX-this.startData.clientX;
            const subY=e.changedTouches[0].clientY - this.startData.clientY;
	    if(subY>50 || subY<-50){
			        console.log('上下滑')
			    }else{
			        if(subX>50){
			            this.prevMonth()
			        }else if(subX<-50){
			            this.nextMonth()
			        }
			    }
			}		 
  • 日历组件全部代码
<template>
	<view class="content" @touchstart="start" @touchend="end">
		<view>
			<view class="year-month header">
				<view @click="prevMonth"><i class='iconfont lefticon icon-zihoudianjisanjiaoxuanze1x'></i></view>
				<view class='time-picker'>
					<picker mode="date" fields="month" @change="monthChange" :start="startYear" :end="endYear">
						<text class="bold">{{time.year}}年{{time.month + 1}}月</text>
					</picker>
				</view>
				<view @click="nextMonth"><i class='iconfont icon-zihoudianjisanjiaoxuanze1x'></i></view>
			</view>
			<view class="flex year-week">
				<view class="week" v-for="item in weeks">
					{{item}}
				</view>
			</view>
			<view class="flex-wrap day-all-box" >
				<template v-for="item in visibleDays" :Key="item.day">
					<view class="day-box flex-column" :class="[{istodaypadding: !isToday(item.day)}]">
						<div class='day-back' :class="[{istodayback: isToday(item.day)},{isselected: isClick(item.day)},]" @click="clickDate(item.day,item.data)">
							<text class="day"   :class="[
								{isselectedtext: isClick(item.day)},
								{notCurrentMonth: !isCurrentMonth(item.day)},
								{noScore:hasScore(item.data.dot,item.data.status)},
								{istodaytext: isToday(item.day)}
							]">{{item.day | dayFilter}}</text>
						</div>
						<!-- 是否展示文字 -->
						<template v-if="showText">
							<text v-if="isCurrentMonth(item.day)" class="day-text" :style="{color: textColor}">
								{{item.data.value || ''}}
							</text>
						</template>
				
						<template v-if="showDot">
							<text v-if="isCurrentMonth(item.day) && item.data.dot && (item.data.status===2)"
								class="day-dot"></text>
						</template>
					</view>
				</template>
			</view>
		</view>
	</view>
</template>

<script>
	const getYearMonthDay = (date) => {
		let year = date.getFullYear();
		let month = date.getMonth();
		let day = date.getDate();
		return {
			year,
			month,
			day
		}
	}
	const getDate = (year, month, day) => {
		return new Date(year, month, day)
	}
	export default {
		data() {
			return {
				startYear: '2015-01-01',
				endYear: '2099-12-31',
				jArr: [1, 2, 3, 4, 5, 6, 7],
				value: new Date(),
				weeks: ['日', '一', '二', '三', '四', '五', '六'],
				click_time: {},
				month_data: this.extraData,
				time: this.defaultTime,
				startData:{
					clientY:"",
					clientX:"",
				}
			}
		},
		created(){
			// console.log('time:',this.time)
		},
		props: {
			bgColor: {
				type: String,
				default: '#4198f8'
			},
			selColor: {
				type: String,
				default: '#4198f8'
			},
			textColor: {
				type: String,
				default: '#4198f8'
			},
			defaultTime: {
				type: Object,
				default: () => {
					return {
						year: getYearMonthDay(new Date()).year,
						month: getYearMonthDay(new Date()).month,
					}
				}
			},
			extraData: {
				type: Array,
				default: () => {
					return [] // {date: '2020-6-3', value: '签到', dot: true, active: true}
				}
			},
			showText: {
				type: Boolean,
				default: true
			},
			showDot: {
				type: Boolean,
				default: false
			},
			showToday:{
				type: Boolean,
				default: true
			}
		},
		filters: {
			dayFilter(val) {
				return val.getDate();
			}
		},
		watch: {
			extraData: {
				handler(newV, oldV) {
					if (newV !== oldV) {
						this.month_data = newV
					}
				},
				deep: true
			}
		},
		computed: {
			visibleDays() { // 计算当月展示日期
				let {
					time: {
						year,
						month
					},
					month_data
				} = this;
				let currentFirstDay = getDate(year, month, 1);
				let week = currentFirstDay.getDay();
				let startDay = currentFirstDay - week * 60 * 60 * 1000 * 24;
				let arr = [];
				for (let i = 0; i < 42; i++) {
					let day = new Date(startDay + i * 60 * 60 * 1000 * 24);
					let {
						year: dayY,
						month: dayM,
						day: dayD
					} = getYearMonthDay(day);
					let data = {};
					for (let item of month_data) {
						let dateString = item.date;
						let dateArr = dateString.indexOf('-') !== -1 ?
							dateString.split('-') :
							dateString.indexOf('/') !== -1 ?
							dateString.split('/') :
							[];
						if (dateArr.length === 3 &&
							Number(dateArr[0]) === Number(dayY) &&
							Number(dateArr[1]) === (Number(dayM) + 1) &&
							Number(dateArr[2]) === Number(dayD)) {
							data = item
						}
					}
					let obj = {
						day,
						data
					}
					arr.push(obj)
				}
				return arr
			}
		},
		mounted() {},
		methods: {
			isCurrentMonth(date) { // 是否当月
				let {
					year,
					month
				} = getYearMonthDay(getDate(this.time.year, this.time.month, 1));
				let {
					year: y,
					month: m
				} = getYearMonthDay(date);
				return year === y && month === m;
			},
			isToday(date) { // 是否当天
				let {
					year,
					month,
					day
				} = getYearMonthDay(new Date());
				let {
					year: y,
					month: m,
					day: d
				} = getYearMonthDay(date);
				return year === y && month === m && day === d;
			},
			isClick(date) { // 是否是点击日期
				let {
					click_time
				} = this;
				if (!click_time.day) return false;
				let {
					year,
					month,
					day
				} = getYearMonthDay(getDate(click_time.year, click_time.month, click_time.day));
				let {
					year: y,
					month: m,
					day: d
				} = getYearMonthDay(date);
				return year === y && month === m && day === d;
			},
			hasScore(dot,status){ //是否有任务
				if(dot && status===2){
					return false
				}else{
					return true
				}
			
			},
			async clickDate(date,data) { // 点击日期
			let {status}=data
				let {
					year,
					month,
					day
				} = getYearMonthDay(date);
				this.click_time = {
					year,
					month,
					day
				};
				this.$emit('calendarTap', {
					year,
					month,
					day,
					status
				})
			},
			prevMonth() { // 上一月
				let {
					time: {
						year,
						month
					}
				} = this;
				let d = getDate(year, month, 1);
				d.setMonth(d.getMonth() - 1);
				this.time = getYearMonthDay(d);
				const {year:years,month:months,day:days}=this.time
				this.click_time = {
					year:years,
					month:months+1,
					day:days
				};
				// this.click_time = {};
				this.$emit('monthTap', getYearMonthDay(d))
			},
			nextMonth() { // 下一月
				// 获取当前的年月的日期
				let {
					time: {
						year,
						month
					}
				} = this;
				let d = getDate(year, month, 1);
				d.setMonth(d.getMonth() + 1);
				this.time = getYearMonthDay(d);
				const {year:years,month:months,day:days}=this.time
				this.click_time = {
					year:years,
					month:months+1,
					day:days
				};
				// this.click_time = {};
				this.$emit('monthTap', getYearMonthDay(d))
			},
			monthChange(e) {
				let {
					value
				} = e.detail;
				let timeArr = value.split('-');
				this.time = {
					year: timeArr[0],
					month: timeArr[1] - 1,
					day: timeArr[2].split(' ')[0]
				};
				this.click_time = {
					year: timeArr[0],
					month: timeArr[1]-1,
					day: timeArr[2].split(' ')[0]
				};
				this.$emit('monthSelectTap', {
					year: timeArr[0],
					month: timeArr[1] - 1,
					day: timeArr[2].split(' ')[0]
				})
			},
			//滑动监听
			start(e){  
			    this.startData.clientX=e.changedTouches[0].clientX;             
			    this.startData.clientY=e.changedTouches[0].clientY;
			},
			end(e){
			    const subX=e.changedTouches[0].clientX-this.startData.clientX;
			    const subY=e.changedTouches[0].clientY - this.startData.clientY;
			    if(subY>50 || subY<-50){
			        console.log('上下滑')
			    }else{
			        if(subX>50){
			            this.prevMonth()
			        }else if(subX<-50){
			            this.nextMonth()
			        }
			    }
			}
		}
	}
</script>

<style scoped lang="scss">
	.content {
		width: 100%;
		margin: 0 auto;
	}
	.iconfont{
		color: #2B3530;
		font-size: 28upx;
		font-weight: 600;
	}
	.lefticon{
		transform: rotateY(180deg);
	}
	.header{
		display: flex;
		justify-content: center;
		align-items: center;
		.time-picker{
			margin: 0 20upx;
		}
	}
	.flex {
		width: 100%;
		display: flex;
		align-items: center;
		justify-content: space-between;
		flex-direction: row;
	}

	.flex-wrap {
		width: 100%;
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: space-between;
		flex-direction: row;
	}

	.flex-column {
		width: 100%;
		height: 76upx;
		display: flex;
		flex-direction: column;
		justify-content: flex-start;
		align-items: center;
	}

	.flex-item {
		flex: 1;
	}
	.bold {
		height: 56upx;
		font-size: 40upx;
		font-family: PingFangSC-Semibold, PingFang SC;
		font-weight: 600;
		color: #2B3530;
		line-height: 56upx;
	}

	.year-month {
		width: 100%;
		padding:0 40upx;
		// padding-top: 20upx;
	}
	.week {
		width: 90upx;
		height: 17px;
		font-size: 12px;
		font-family: PingFangSC-Regular, PingFang SC;
		font-weight: 400;
		color: #2B3530;
		line-height: 17px;
		text-align: center;
		
	}

	.day-box {
		width: 90upx;
		height: 80upx;
		// text-align: center;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		// margin-top: 6upx;
		// margin-bottom: 20upx;
		// justify-content: center;
	}
	.day-back{
		width: 60upx;
		height: 60upx;
		text-align: center;
		line-height: 60upx;
	}
	.istodayback{
		background: linear-gradient(180deg, #61C6FD 0%, #3296FA 100%);
		box-shadow: 0px 6px 14px 6px rgba(211, 216, 220, 0.2);
		border-radius: 4px;
	}
	.istodaytext{
		color: #fff !important;
	}
	.isselected{
		background: linear-gradient(180deg, #61C6FD 0%, #3296FA 100%);
		box-shadow: 0px 6px 14px 6px rgba(211, 216, 220, 0.2);
		border-radius: 4px;
	}
	.isselectedtext{
		color: #fff !important;
	}
	.day-all-box{
		padding:0 40upx;
	}
	.day {
		width: 40upx;
		// height: 42upx;
		font-size: 30upx;
		font-family: PingFangSC-Semibold, PingFang SC;
		font-weight: 600;
		color: #2B3530;
		// line-height: 42upx;
		border-radius: 50%;
	}

	.day-text {
		font-size: 22upx;
	}

	.day-dot {
		width: 8upx;
		height: 8upx;
		border-radius: 50%;
		// background: #3ECE81;
		background: linear-gradient(180deg, #61C6FD 0%, #3296FA 100%);
		padding-top:6upx ;
		&.dot-red {
			// +background: #FF726E;
		}
	}

	.today,
	.selected {
		background: #4198f8;
		color: #ffffff;
	}

	.notCurrentMonth {
		color: #999999;
		pointer-events: none;
		background: none;
	}
	.noScore{
		color: #999999;
		pointer-events: none;
		background: none;
	}
	.year-week{
		margin-top: 20upx;
		margin-bottom: 20upx;
		padding:0 40upx;
	}
	.todayday-text{
		width: 28upx;
		height: 28upx;
		line-height: 28upx;
		background: #3ECE81;
		border-radius: 2px;
		font-size: 16upx;
		font-family: PingFangSC-Semibold, PingFang SC;
		font-weight: 600;
		color: #FFFFFF;
		text-align: center;
		margin-bottom: 6upx;
	}
	.istodaypadding{
		// padding-top: 34upx;
	}
</style>

2.新建calendarpage.vue组件对日历进一步封装,将属性传入,同时将日历组件内,日期切换等事件接收。

<template>
    <div class='calendarbox'>
	<Calendar 
            name="calendar" 
            bgColor="#3ECE81" 
            selColor="#3ECE81" 
            textColor="#4198f8" 
            :showText="false"
            :showDot="true" 
            :extraData="extraData" 
            @calendarTap="calendarTap" 
            @monthTap="monthTap" 
            @monthSelectTap="monthSelectTap"
            />
    </div>
</template>

<script>
	import Calendar from './calendars.vue';
	export default {
		props: {
			extraData: {
				type: Array,
				default: () => {
					return []
				}
			},
			contentList:{
				type: Array,
				default: () => {
					return []
				}
			}
		},
		components: {
			Calendar
		},
		data() {
			return {

			}
		},
		methods: {
			// 点击选择日期改变
			calendarTap(e) {
				e.month += 1
				this.$emit('SelectTimeDay',e)
			},
			// 月份左右改变
			monthTap(val) {
				let {
					year,
					month
				} = val;
				let date = `${parseTime(
					`${year}-${month+1}`,
					'{y}-{m}')}-01`
				this.$emit('SelectTimeMonth',date)
			},
			// 月份选择器改变
			monthSelectTap(val){
				let {
					year,
					month,
					day
				} = val;
                                // parseTime为封装的日期处理算法可自行封装
				let date = `${parseTime(
					`${year}-${month+1}`,
					'{y}-{m}')}-${day}`

				this.$emit('SelectTimeMonthPicker',date)
			},
		}
	}
</script>

3.外部组件需要传入的部分参数形式

	extraData:[{
		date: '2021-11-10',
		value: '有任务',
		dot: true,
		active: true,
		status:2
	}], // 需要标记的特殊日期
	contentList:[
        {time:'2022-06-17',status:'进行中',projectTitle:'事件名',hostName:'ss'
        ], // 展示日期待办任务