成果展示:
完整代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>动态阻尼下拉刷新</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<style>
.hide-scrollbar::-webkit-scrollbar {
display: none;
/* Safari 和 Chrome */
}
</style>
<body class="bg-gray-100 flex items-center justify-center h-screen" x-data="{showDatePicker:false}">
<button @click="showDatePicker = true" class="bg-blue-500 text-white px-4 py-2 rounded-full">打开日期选择器</button>
<div x-show="showDatePicker" x-data="datePicker()" class="bg-white p-4 rounded-lg shadow-md w-80" x-init="$watch('showDatePicker', value => { if (value) $nextTick(() => init()); })">
<div class="relative flex justify-between">
<!-- 两条分割线 -->
<div class="absolute w-full border-b border-gray-600 top-1/2 transition -translate-y-4"></div>
<div class="absolute w-full border-b border-gray-600 bottom-1/2 transition translate-y-4"></div>
<!-- 年份选择 -->
<div class=" flex-1 mx-1">
<div class="h-[320px] pb-[150px] pt-[150px] overflow-y-scroll hide-scrollbar rounded" x-ref="yearScroll">
<template x-for="year in years" :key="year">
<div
class="text-center py-2 cursor-pointer"
:class="{ 'font-bold ': selectedYear === year }"
@click="selectYear(year)"
>
<span x-text="`${year}年`"></span>
</div>
</template>
</div>
</div>
<!-- 月份选择 -->
<div class="flex-1 mx-1">
<div class="h-[320px] pb-[150px] pt-[150px] overflow-y-scroll hide-scrollbar rounded" x-ref="monthScroll">
<template x-for="month in months" :key="month">
<div
class="text-center py-2 cursor-pointer"
:class="{ 'font-bold': selectedMonth === month }"
@click="selectMonth(month)"
>
<span x-text="`${month}月`"></span>
</div>
</template>
</div>
</div>
<!-- 日期选择 -->
<div class="flex-1 mx-1">
<div class="h-[320px] pb-[150px] pt-[150px] overflow-y-scroll hide-scrollbar rounded" x-ref="dayScroll">
<template x-for="day in days" :key="day">
<div
class="text-center py-2 cursor-pointer"
:class="{ 'font-bold': selectedDay === day }"
@click="selectDay(day)"
>
<span x-text="`${day}日`"></span>
</div>
</template>
</div>
</div>
</div>
<button
class="w-full bg-red-500 text-white px-4 py-2 rounded-full mt-4"
>
确认
</button>
<!-- 显示选中结果 -->
<div class="mt-4 text-center">
<span class="text-lg font-bold">选中日期:</span>
<span x-text="`${selectedYear}年 ${selectedMonth}月 ${selectedDay}日`"></span>
</div>
</div>
</body>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('datePicker', () => ({
years: [],
months: Array.from({ length: 12 }, (_, i) => i + 1),
days: [],
systemDate: {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate()
},
selectedYear: null,
selectedMonth: null,
selectedDay: null,
scrollTimeouts: {},
// 初始化函数
init() {
this.years = this.generateYearRange(1920, 2025);
this.resetToSystemDate();
this.updateDays();
this.addScrollListeners();
this.$nextTick(() => {
const observer = new ResizeObserver(() => {
this.scrollToCurrent();
});
observer.observe(this.$el);
setTimeout(() => observer.disconnect(), 300);
});
},
// 重置为系统时间
resetToSystemDate() {
this.selectedYear = this.systemDate.year;
this.selectedMonth = this.systemDate.month;
this.selectedDay = this.systemDate.day;
},
// 生成年份
generateYearRange(start, end) {
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
},
// 检查闰年
isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
},
// 获取特定年和月的天数
daysInMonth(year, month) {
const monthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if (month === 2 && this.isLeapYear(year)) return 29;
return monthDays[month - 1] || 0;
},
// 更新天数列表
updateDays() {
const daysInMonth = this.daysInMonth(this.selectedYear, this.selectedMonth);
this.days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
this.selectedDay = Math.min(this.selectedDay, daysInMonth);
},
// 获取滚动元素的动态内边距和项高度
getScrollElementDimensions(ref) {
const container = this.$refs[ref];
if (container) {
const style = getComputedStyle(container);
const paddingTop = parseInt(style.paddingTop, 10);
const paddingBottom = parseInt(style.paddingBottom, 10);
const itemHeight = container.querySelector('li') ? container.querySelector('li').offsetHeight : 40;
// 想知道每一项多高就把注释解了
// console.log(`${ref}的内边距:`, paddingTop, paddingBottom, `项高度:`, itemHeight);
return { paddingTop, paddingBottom, itemHeight };
}
return { paddingTop: 140, paddingBottom: 140, itemHeight: 40 }; // 默认值
},
// 滚动到当前日期
scrollToCurrent() {
this.$nextTick(() => {
this.scrollToPosition('yearScroll', this.selectedYear - this.years[0]);
this.scrollToPosition('monthScroll', this.selectedMonth - 1);
this.scrollToPosition('dayScroll', this.selectedDay - 1);
});
},
// 滚动到指定位置
scrollToPosition(type, index) {
const { paddingTop, paddingBottom, itemHeight } = this.getScrollElementDimensions(type);
const container = this.$refs[type];
if (!container || index < 0) return;
const scrollPosition = Math.max(
0,
index * itemHeight - (container.clientHeight / 2 - itemHeight / 2) + paddingTop
);
container.scrollTo({
top: scrollPosition,
behavior: 'smooth',
});
},
// 添加滚动事件监听
addScrollListeners() {
['yearScroll', 'monthScroll', 'dayScroll'].forEach(type => {
const ref = this.$refs[type];
if (!ref) return;
ref.addEventListener('scroll', () => this.debounceScrollEnd(ref, type));
});
},
// 防抖处理的滚动结束事件
debounceScrollEnd(container, type) {
if (this.scrollTimeouts[type]) clearTimeout(this.scrollTimeouts[type]);
this.scrollTimeouts[type] = setTimeout(() => this.handleScrollEnd(container, type), 150);
},
// 处理滚动结束事件
handleScrollEnd(container, type) {
const { itemHeight, paddingTop } = this.getScrollElementDimensions(type);
const scrollPosition = container.scrollTop - paddingTop + container.clientHeight / 2- itemHeight / 2;
let closestIndex = Math.round(scrollPosition / itemHeight);
console.log(`${type}滚动结束,选择索引:`, scrollPosition);
let maxIndex;
if (type === 'yearScroll') {
maxIndex = this.years.length - 1;
closestIndex = Math.max(0, Math.min(closestIndex, maxIndex));
this.selectYear(this.years[closestIndex]);
} else if (type === 'monthScroll') {
maxIndex = this.months.length - 1;
closestIndex = Math.max(0, Math.min(closestIndex, maxIndex));
this.selectMonth(this.months[closestIndex]);
} else if (type === 'dayScroll') {
maxIndex = this.days.length - 1;
closestIndex = Math.max(0, Math.min(closestIndex, maxIndex));
this.selectDay(this.days[closestIndex]);
}
// 校正滚动
const expectedScrollPosition = closestIndex * itemHeight - (container.clientHeight / 2 - itemHeight / 2) + paddingTop;
if (Math.abs(container.scrollTop - expectedScrollPosition) > 1) {
container.scrollTo({
top: expectedScrollPosition,
behavior: 'smooth',
});
}
},
// 选择年份
selectYear(year) {
if (this.selectedYear !== year) {
this.selectedYear = year;
this.selectedMonth = 1;
this.selectedDay = 1;
this.updateDays();
this.scrollToCurrent();
}
},
// 选择月份
selectMonth(month) {
if (this.selectedMonth !== month) {
this.selectedMonth = month;
this.selectedDay = 1;
this.updateDays();
this.scrollToCurrent();
}
},
// 选择日期
selectDay(day) {
if (this.selectedDay !== day) {
this.selectedDay = day;
this.scrollToCurrent();
}
},
}));
});
</script>
</html>
开发逻辑说明
这个时间选择器的设计逻辑主要围绕如何实现滚动选择 年、月、日 并保证选中项居中展开。以下是其开发思路的分解:
1. 使用数组存储数据
- 使用
years
、months
和days
三个数组分别存储年份、月份和日期。 - 动态生成年份范围(如 1920-2025),并通过月份和年份计算出特定月份的天数。
2. 自动调整容器与视口的关系
- 动态获取容器的高度,在函数getScrollElementDimensions(ref)中使用getComputedStyle计算容器内边距以及每一项的高度。
- 通过计算容器高度,内边距高度以及每一项高度实现元素的正确选取。
3. 上下留白实现居中显示
- 为滚动容器添加上下留白(
paddingTop
和paddingBottom
),通过内边距带来的位置偏移实现容器的居中显示。
4. 平滑滚动与居中校正
- 通过
scrollTo
方法,将选中的项目平滑滚动到中心。 - 在滚动停止后,根据当前滚动位置校正到最近的有效日期项。
5. 事件监听与防抖
- 为滚动容器添加
scroll
事件监听器,并使用防抖处理(setTimeout
和clearTimeout
),避免频繁触发滚动结束逻辑。 - 在滚动停止时,计算最接近的日期项并自动调整滚动位置。
6. 数据联动
- 当年份或月份发生变化时,重新计算日期数组,确保日期数据与所选年份和月份一致。
- 用户交互时(如滚动选择年份),自动联动更新月份和日期,避免选择无效日期。
7. 初始化与动态更新
- 在组件初始化时,通过
ResizeObserver
自动监听元素尺寸变化,确保时间选择器能正确获取到容器高度。 - 提供重置功能(如恢复系统时间)以便快速回到默认状态。