<template>
|
<div class="time-picker">
|
<div class="picker-column" ref="hourRef" @scroll="onScroll('hour')" :class="disabled ? 'disabled' : ''">
|
<div
|
v-for="(h, index) in loopHours"
|
:key="index"
|
class="picker-item hours"
|
:class="getClassByIndex(index, selectedIndexHour)"
|
:style="getStyleByIndex(index, selectedIndexHour)"
|
>
|
{{ h.toString().padStart(2, "0") }}
|
</div>
|
</div>
|
<span class="colon">:</span>
|
<div class="picker-column" ref="minuteRef" @scroll="onScroll('minute')" :class="disabled ? 'disabled' : ''">
|
<div
|
v-for="(m, index) in loopMinutes"
|
:key="index"
|
class="picker-item minutes"
|
:class="getClassByIndex(index, selectedIndexMinute)"
|
:style="getStyleByIndex(index, selectedIndexMinute)"
|
>
|
{{ m.toString().padStart(2, "0") }}
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { ref, watch, onMounted, nextTick } from "vue";
|
|
const props = defineProps<{ modelValue: string, disabled: boolean }>();
|
const emit = defineEmits(["update:modelValue"]);
|
|
const hours = Array.from({ length: 24 }, (_, i) => i);
|
const minutes = Array.from({ length: 60 }, (_, i) => i);
|
|
// 为循环滚动,前面后面都各补两个,要不要选不中最后一个
|
function createLoopArray(arr: number[]) {
|
return [...arr.slice(-2), ...arr, ...arr.slice(0, 2)];
|
}
|
|
const loopHours = createLoopArray(hours);
|
const loopMinutes = createLoopArray(minutes);
|
|
const hourRef = ref<HTMLElement | null>(null);
|
const minuteRef = ref<HTMLElement | null>(null);
|
|
const ITEM_REM = 0.26;
|
const itemHeight = remToPx(ITEM_REM);
|
|
const selectedIndexHour = ref(2);
|
const selectedIndexMinute = ref(2);
|
|
const selectedHour = ref(hours[0]);
|
const selectedMinute = ref(minutes[0]);
|
|
// 标记程序主动滚动,防止滚动事件死循环
|
const isProgrammaticScroll = {
|
hour: false,
|
minute: false,
|
};
|
|
// 初始化滚动,选中传入时间
|
onMounted(() => {
|
const [h, m] = props.modelValue.split(":").map(Number);
|
const hourIdx = hours.indexOf(h);
|
const minuteIdx = minutes.indexOf(m);
|
|
selectedIndexHour.value = hourIdx === -1 ? 2 : hourIdx + 2;
|
selectedIndexMinute.value = minuteIdx === -1 ? 2 : minuteIdx + 2;
|
|
selectedHour.value = h;
|
selectedMinute.value = m;
|
|
nextTick(() => {
|
scrollToSelected("hour", selectedIndexHour.value);
|
scrollToSelected("minute", selectedIndexMinute.value);
|
});
|
});
|
|
watch(
|
() =>
|
`${selectedHour.value.toString().padStart(2, "0")}:${selectedMinute.value
|
.toString()
|
.padStart(2, "0")}`,
|
(val) => {
|
emit("update:modelValue", val);
|
}
|
);
|
|
watch(
|
() => props.modelValue,
|
(newVal) => {
|
const [h, m] = newVal.split(":").map(Number);
|
const hourIdx = hours.indexOf(h);
|
const minuteIdx = minutes.indexOf(m);
|
|
if (hourIdx !== -1) {
|
selectedIndexHour.value = hourIdx + 2;
|
selectedHour.value = h;
|
scrollToSelected("hour", selectedIndexHour.value);
|
}
|
|
if (minuteIdx !== -1) {
|
selectedIndexMinute.value = minuteIdx + 2;
|
selectedMinute.value = m;
|
scrollToSelected("minute", selectedIndexMinute.value);
|
}
|
}
|
);
|
|
function onScroll(type: "hour" | "minute") {
|
if (props.disabled) return;
|
if (isProgrammaticScroll[type]) {
|
// 程序滚动,忽略,避免死循环
|
isProgrammaticScroll[type] = false;
|
return;
|
}
|
|
const refEl = type === "hour" ? hourRef.value : minuteRef.value;
|
if (!refEl) return;
|
|
const scrollTop = refEl.scrollTop;
|
let index = Math.round(scrollTop / itemHeight + 2);
|
|
const arrLen = type === "hour" ? hours.length : minutes.length;
|
|
if (index < 2) {
|
isProgrammaticScroll[type] = true;
|
refEl.scrollTop = itemHeight * (arrLen + (index - 2));
|
index = arrLen + (index - 2);
|
} else if (index > arrLen + 1) {
|
isProgrammaticScroll[type] = true;
|
refEl.scrollTop = itemHeight * (index - arrLen - 2);
|
index = index - arrLen - 2;
|
}
|
|
if (type === "hour") {
|
selectedIndexHour.value = index;
|
selectedHour.value = loopHours[index];
|
} else {
|
selectedIndexMinute.value = index;
|
selectedMinute.value = loopMinutes[index];
|
}
|
}
|
|
function scrollToSelected(type: "hour" | "minute", index: number) {
|
const refEl = type === "hour" ? hourRef.value : minuteRef.value;
|
if (!refEl) return;
|
|
isProgrammaticScroll[type] = true;
|
refEl.scrollTo({
|
top: (index - 2) * itemHeight,
|
behavior: "auto",
|
});
|
}
|
|
function getClassByIndex(index: number, selectedIndex: number) {
|
const diff = Math.abs(index - selectedIndex);
|
return {
|
active: diff === 0,
|
medium: diff === 1,
|
small: diff === 2,
|
};
|
}
|
|
function getStyleByIndex(index: number, selectedIndex: number) {
|
const diff = Math.min(Math.abs(index - selectedIndex), 2);
|
return {
|
opacity: diff === 2 ? 0.4 : 1,
|
zIndex: 10 - diff,
|
transition: "opacity 0.3s ease",
|
};
|
}
|
|
function remToPx(rem: number) {
|
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
}
|
</script>
|
|
<style scoped lang="less">
|
.time-picker {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
height: 1.3rem; // 5 * 0.4rem 每个item高度0.4rem
|
overflow: hidden;
|
|
.picker-column {
|
height: 1.3rem;
|
width: 0.7rem;
|
overflow-y: scroll;
|
scroll-snap-type: y mandatory;
|
-webkit-overflow-scrolling: touch;
|
scrollbar-width: none; /* Firefox */
|
-ms-overflow-style: none; /* IE 10+ */
|
&::-webkit-scrollbar {
|
display: none; /* Chrome Safari */
|
}
|
&.disabled{
|
overflow: hidden;
|
}
|
|
.picker-item {
|
height: 0.26rem;
|
line-height: 0.26rem;
|
text-align: center;
|
font-size: 0.2rem;
|
scroll-snap-align: center;
|
user-select: none;
|
cursor: pointer;
|
transition: color 0.3s ease, font-size 0.3s ease;
|
|
&.active {
|
font-size: 0.28rem;
|
font-weight: 700;
|
color: #111;
|
&.hours {
|
text-align: left;
|
}
|
&.minutes {
|
text-align: right;
|
}
|
}
|
&.medium {
|
font-size: 0.22rem;
|
color: #666;
|
&.hours {
|
text-align: left;
|
padding-left: 0.16rem;
|
}
|
&.minutes {
|
text-align: right;
|
padding-right: 0.16rem;
|
}
|
}
|
&.small {
|
font-size: 0.18rem;
|
color: #aaa;
|
}
|
}
|
}
|
|
.colon {
|
font-size: 0.28rem;
|
font-weight: 600;
|
color: #444;
|
user-select: none;
|
}
|
}
|
</style>
|