最近研究了 DatePicker 的實現原理後做了一個 vue 的 DatePicker 元件,今天帶大家一步一步實現 DatePicker 的 vue 元件。
原理
DatePicker 的原理是——計算日曆皮膚中當月或選中月份的總天數及前後月份相近的日子,根據點選事件計算日曆皮膚顯示內容,以及將所選值賦值給
<input/>
標籤。
實現
- CSS 程式碼於文章末尾處
1. 構思頁面結構
DatePicker 元件由輸入框和日曆皮膚組成,寫好頁面主體結構。
<div class="date-picker">
<input class="input" v-model="dateValue" @click="openPanel"/>
<transition name="fadeDownBig">
<div class="date-panel" v-show="panelState"></div>
</transiton>
</div>
複製程式碼
輸入框<input>
點選顯示或隱藏日曆皮膚,openPanel()方法改變 panelState 布林值控制日曆皮膚的顯示隱藏。
日曆皮膚由頂部條和皮膚兩部分組成,而皮膚則由年份選擇皮膚,月份選擇皮膚,日期選擇皮膚所組成,結構如下:
<div class="date-panel" v-show="panelState">
<!-- 頂部按鈕及年月顯示條 -->
<div class="topbar">
<span @click="leftBig"><<</span>
<span @click="left"><</span>
<span class="year" @click="panelType = 'year'">{{tmpYear}}</span>
<span class="month" @click="panelType = 'month'">{{changeTmpMonth}}</span>
<span @click="right">></span>
<span @click="rightBig">>></span>
</div>
<!-- 年皮膚 -->
<div class="type-year" v-show="panelType === 'year'">
<ul class="year-list">
<li v-for="(item, index) in yearList"
:key="index"
@click="selectYear(item)"
>
<span :class="{selected: item === tmpYear}" >{{item}}</span>
</li>
</ul>
</div>
<!-- 月皮膚 -->
<div class="type-year" v-show="panelType === 'month'">
<ul class="year-list">
<li v-for="(item, index) in monthList"
:key="index"
@click="selectMonth(item)"
>
<span :class="{selected: item.value === tmpMonth}" >{{item.label}}</span>
</li>
</ul>
</div>
<!-- 日期皮膚 -->
<div class="date-group" v-show="panelType === 'date'">
<span v-for="(item, index) in weekList" :key="index" class="weekday">{{item.label}}</span>
<ul class="date-list">
<li v-for="(item, index) in dateList"
v-text="item.value"
:class="{preMonth: item.previousMonth, nextMonth: item.nextMonth,
selected: date === item.value && month === tmpMonth && item.currentMonth, invalid: validateDate(item)}"
:key="index"
@click="selectDate(item)">
</li>
</ul>
</div>
</div>
複製程式碼
2. 頁面資料實現
DatePicker 所對應的 data 程式碼
data() {
return {
dateValue: "", // 輸入框顯示日期
date: new Date().getDate(), // 當前日期
panelState: false, // 初始值,預設panel關閉
tmpMonth: new Date().getMonth(), // 臨時月份,可修改
month: new Date().getMonth(),
tmpYear: new Date().getFullYear(), // 臨時年份,可修改
weekList: [
{ label: "Sun", value: 0 },
{ label: "Mon", value: 1 },
{ label: "Tue", value: 2 },
{ label: "Wed", value: 3 },
{ label: "Thu", value: 4 },
{ label: "Fri", value: 5 },
{ label: "Sat", value: 6 }
], // 周
monthList: [
{ label: "Jan", value: 0 },
{ label: "Feb", value: 1 },
{ label: "Mar", value: 2 },
{ label: "Apr", value: 3 },
{ label: "May", value: 4 },
{ label: "Jun", value: 5 },
{ label: "Jul", value: 6 },
{ label: "Aug", value: 7 },
{ label: "Sept", value: 8 },
{ label: "Oct", value: 9 },
{ label: "Nov", value: 10 },
{ label: "Dec", value: 11 }
], // 月
nowValue: 0, // 當前選中日期值
panelType: "date" // 皮膚狀態
};
},
複製程式碼
DatePicker 的核心在於日期皮膚的資料。我們知道,一個月最多31天,最少28天。皮膚按週日至週六設計,最極端的情況如下:
最多的極端情況:
日 | 一 | 二 | 三 | 四 | 五 | 六 |
---|---|---|---|---|---|---|
* | * | * | * | * | * | 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 1 | 2 | 3 | 4 | 5 |
最少的極端情況:
日 | 一 | 二 | 三 | 四 | 五 | 六 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
根據上表我們可以得知一個月最多佔六個星期,最少四個星期,所以日曆皮膚必須設計為 6 行,剩餘的用下個月的日期補上,最多補14天。因此日期陣列可以這麼設計:
computed: {
dateList() {
//獲取當月的天數
let currentMonthLength = new Date(
this.tmpYear,
this.tmpMonth + 1,
0
).getDate();
//先將當月的日期塞入dateList
let dateList = Array.from(
{ length: currentMonthLength },
(val, index) => {
return {
currentMonth: true,
value: index + 1
};
}
);
// 獲取當月1號的星期是為了確定在1號前需要插多少天
let startDay = new Date(this.tmpYear, this.tmpMonth, 1).getDay();
// 確認上個月一共多少天
let previousMongthLength = new Date(
this.tmpYear,
this.tmpMonth,
0
).getDate();
// 在1號前插入上個月日期
for (let i = 0, len = startDay; i < len; i++) {
dateList = [
{ previousMonth: true, value: previousMongthLength - i }
].concat(dateList);
}
// 補全剩餘位置,至少14天,則 i < 15
for (let i = 1, item = 1; i < 15; i++, item++) {
dateList[dateList.length] = { nextMonth: true, value: i };
}
return dateList;
},
}
複製程式碼
changeTmpMonth 為選擇月份後顯示的文案,yearList 為年份列表,為了與月份數量保持一致,我們也設長度為12.
computed: {
changeTmpMonth() {
return this.monthList[this.tmpMonth].label;
},
// 通過改變this.tmpYear則可以改變年份陣列
yearList() {
return Array.from({ length: 12 }, (value, index) => this.tmpYear + index);
}
}
複製程式碼
3. 實現頁面功能
(1)皮膚切換功能
- 點選輸入框,除了開啟日曆皮膚,同時也預設為日期皮膚
openPanel() {
this.panelState = !this.panelState;
this.panelType = "date";
},
複製程式碼
- 點選 2018 年份進入年份皮膚,點選相對應年份顯示該年份並進入月份選擇皮膚
<span class="year" @click="panelType = 'year'">{{tmpYear}}</span>
複製程式碼
selectYear(item) {
this.tmpYear = item;
this.panelType = "month";
},
複製程式碼
- 點選 Aug 月份進入月份皮膚,點選相對應月份顯示該月份並進入日期選擇皮膚
<span class="month" @click="panelType = 'month'">{{changeTmpMonth}}</span>
複製程式碼
selectMonth(item) {
this.tmpMonth = item.value;
this.panelType = "date";
},
複製程式碼
點選日期選擇日期,關閉皮膚同時賦值給輸入框
// methods
selectDate(item) {
// 賦值 當前 nowValue,用於控制樣式突出顯示當前月份日期
this.nowValue = item.value;
// 選擇了上個月
if (item.previousMonth) this.tmpMonth--;
// 選擇了下個月
if (item.nextMonth) this.tmpMonth++;
// 獲取選中日期的 date
let selectDay = new Date(this.tmpYear, this.tmpMonth, this.nowValue);
// 格式日期為字串後,賦值給 input
this.dateValue = this.formatDate(selectDay.getTime());
// 關閉皮膚
this.panelState = !this.panelState;
},
// 日期格式方法
formatDate(date, fmt = this.format) {
if (date === null || date === "null") {
return "--";
}
date = new Date(Number(date));
var o = {
"M+": date.getMonth() + 1, // 月份
"d+": date.getDate(), // 日
"h+": date.getHours(), // 小時
"m+": date.getMinutes(), // 分
"s+": date.getSeconds(), // 秒
"q+": Math.floor((date.getMonth() + 3) / 3), // 季度
S: date.getMilliseconds() // 毫秒
};
if (/(y+)/.test(fmt))
fmt = fmt.replace(
RegExp.$1,
(date.getFullYear() + "").substr(4 - RegExp.$1.length)
);
for (var k in o) {
if (new RegExp("(" + k + ")").test(fmt))
fmt = fmt.replace(
RegExp.$1,
RegExp.$1.length === 1
? o[k]
: ("00" + o[k]).substr(("" + o[k]).length)
);
}
return fmt;
},
// 確認是否為當前月份
validateDate(item) {
if (this.nowValue === item.value && item.currentMonth) return true;
},
複製程式碼
(2)topbar 中左右箭頭功能,具體詳看下面方法
// <
left() {
if (this.panelType === "year") this.tmpYear--;
else {
if (this.tmpMonth === 0) {
this.tmpYear--;
this.tmpMonth = 11;
} else this.tmpMonth--;
}
},
// <<
leftBig() {
if (this.panelType === "year") this.tmpYear -= 12;
else this.tmpYear--;
},
// >
right() {
if (this.panelType === "year") this.tmpYear++;
else {
if (this.tmpMonth === 11) {
this.tmpYear++;
this.tmpMonth = 0;
} else this.tmpMonth++;
}
},
// >>
rightBig() {
if (this.panelType === "year") this.tmpYear += 12;
else this.tmpYear++;
},
複製程式碼
(3) 實現輸入框的雙向繫結及格式規定
props
props: {
value: {
type: [Date, String],
default: ""
},
format: {
type: String,
default: "yyyy-MM-dd"
}
},
複製程式碼
其中 value 支援日期格式和字串,當設定了props時,則需在monted鉤子函式中初始化input 值。format 預設值為 "yyyy-MM-dd", 當然你也可以設定為 "dd-MM-yyyy"等。
mounted() {
if (this.value) {
this.dateValue = this.formatDate(new Date(this.value).getTime());
}
},
複製程式碼
雙向繫結父元件賦值 props 為 value, 子元件傳遞的事件為input, 因此需在 selectDate 方法中 emit 事件及資料給父元件
selectDate(item) {
...
this.$emit("input", selectDay);
},
複製程式碼
這樣,父元件便可以進行雙向繫結了
<Datepicker v-model="time" format="dd-MM-yyyy"/>
複製程式碼
(4)點選頁面其他位置收起日曆皮膚
原理
監聽頁面的點選事件,檢測到有點選事件時關閉皮膚,但點選元件內容時也會觸發點選事件,因此需要在元件內部阻止冒泡。同時,當元件銷燬時,也要及時清除該監聽器。
元件最外層阻止冒泡
<div class="date-picker" @click.stop></div>
複製程式碼
頁面建立設定監聽
mounted() {
...
window.addEventListener("click", this.eventListener);
}
複製程式碼
頁面銷燬清除監聽
destroyed() {
window.removeEventListener("click", this.eventListener);
}
複製程式碼
公共方法
eventListener() {
this.panelState = false;
},
複製程式碼
有用就點個讚唄~
最後,貼上 CSS 程式碼...
- fadeDownBig 後面的樣式為 vue
<transiton>
的動畫特效.
.topbar {
padding-top: 8px;
}
.topbar span {
display: inline-block;
width: 20px;
height: 30px;
line-height: 30px;
color: #515a6e;
cursor: pointer;
}
.topbar span:hover {
color: #2d8cf0;
}
.topbar .year,
.topbar .month {
width: 60px;
}
.year-list {
height: 200px;
width: 210px;
}
.year-list .selected {
background: #2d8cf0;
border-radius: 4px;
color: #fff;
}
.year-list li {
display: inline-block;
width: 70px;
height: 50px;
line-height: 50px;
border-radius: 10px;
cursor: pointer;
}
.year-list span {
display: inline-block;
line-height: 16px;
padding: 8px;
}
.year-list span:hover {
background: #e1f0fe;
}
.weekday {
display: inline-block;
font-size: 13px;
width: 30px;
color: #c5c8ce;
text-align: center;
}
.date-picker {
width: 210px;
text-align: center;
font-family: "Avenir", Helvetica, Arial, sans-serif;
}
.date-panel {
width: 210px;
box-shadow: 0 0 8px #ccc;
background: #fff;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.date-list {
width: 210px;
text-align: left;
height: 180px;
overflow: hidden;
margin-top: 4px;
}
.date-list li {
display: inline-block;
width: 28px;
height: 28px;
line-height: 30px;
text-align: center;
cursor: pointer;
color: #000;
border: 1px solid #fff;
border-radius: 4px;
}
.date-list .selected {
border: 1px solid #2d8cf0;
}
.date-list .invalid {
background: #2d8cf0;
color: #fff;
}
.date-list .preMonth,
.date-list .nextMonth {
color: #c5c8ce;
}
.date-list li:hover {
background: #e1f0fe;
}
input {
display: inline-block;
box-sizing: border-box;
width: 100%;
height: 32px;
line-height: 1.5;
padding: 4px 7px;
font-size: 12px;
border: 1px solid #dcdee2;
border-radius: 4px;
color: #515a6e;
background-color: #fff;
background-image: none;
position: relative;
cursor: text;
transition: border 0.2s ease-in-out, background 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
margin-bottom: 6px;
}
.fadeDownBig-enter-active,
.fadeDownBig-leave-active,
.fadeInDownBig {
-webkit-animation-duration: 0.5s;
animation-duration: 0.5s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.fadeDownBig-enter-active {
-webkit-animation-name: fadeInDownBig;
animation-name: fadeInDownBig;
}
.fadeDownBig-leave-active {
-webkit-animation-name: fadeOutDownBig;
animation-name: fadeOutDownBig;
}
@-webkit-keyframes fadeInDownBig {
from {
opacity: 0.8;
-webkit-transform: translate3d(0, -4px, 0);
transform: translate3d(0, -4px, 0);
}
to {
opacity: 1;
-webkit-transform: none;
transform: none;
}
}
@keyframes fadeInDownBig {
from {
opacity: 0.8;
-webkit-transform: translate3d(0, -4px, 0);
transform: translate3d(0, -4px, 0);
}
to {
opacity: 1;
-webkit-transform: none;
transform: none;
}
}
@-webkit-keyframes fadeOutDownBig {
from {
opacity: 1;
}
to {
opacity: 0.8;
-webkit-transform: translate3d(0, -4px, 0);
transform: translate3d(0, -4px, 0);
}
}
@keyframes fadeOutDownBig {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
複製程式碼