| | |
| | | <template> |
| | | <div class="time-picker"> |
| | | <div class="picker-column" ref="hourRef" @scroll="onScroll('hour')"> |
| | | <div v-for="h in hours" :key="h" class="picker-item" :class="{ active: h === selectedHour }">{{ h.toString().padStart(2, '0') }}</div> |
| | | <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')"> |
| | | <div v-for="m in minutes" :key="m" class="picker-item" :class="{ active: m === selectedMinute }">{{ m.toString().padStart(2, '0') }}</div> |
| | | <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' |
| | | import { ref, watch, onMounted, nextTick } from "vue"; |
| | | |
| | | interface Props { |
| | | modelValue: string // 格式为 "HH:mm" |
| | | } |
| | | const props = defineProps<Props>() |
| | | const emit = defineEmits(['update:modelValue']) |
| | | const props = defineProps<{ modelValue: string }>(); |
| | | const emit = defineEmits(["update:modelValue"]); |
| | | |
| | | const selectedHour = ref(0) |
| | | const selectedMinute = ref(0) |
| | | const hourRef = ref<HTMLDivElement | null>(null) |
| | | const minuteRef = ref<HTMLDivElement | null>(null) |
| | | const hours = Array.from({ length: 24 }, (_, i) => i); |
| | | const minutes = Array.from({ length: 60 }, (_, i) => i); |
| | | |
| | | const hours = Array.from({ length: 24 }, (_, i) => i) |
| | | const minutes = Array.from({ length: 60 }, (_, i) => i) |
| | | |
| | | function scrollTo(refEl: HTMLDivElement | null, index: number) { |
| | | if (!refEl) return |
| | | refEl.scrollTo({ top: index * 40, behavior: 'smooth' }) |
| | | // 为循环滚动,前面后面都各补两个,要不要选不中最后一个 |
| | | function createLoopArray(arr: number[]) { |
| | | return [...arr.slice(-2), ...arr, ...arr.slice(0, 2)]; |
| | | } |
| | | |
| | | function updateModel() { |
| | | const value = `${selectedHour.value.toString().padStart(2, '0')}:${selectedMinute.value.toString().padStart(2, '0')}` |
| | | emit('update:modelValue', value) |
| | | } |
| | | const loopHours = createLoopArray(hours); |
| | | const loopMinutes = createLoopArray(minutes); |
| | | |
| | | function onScroll(type: 'hour' | 'minute') { |
| | | const el = type === 'hour' ? hourRef.value : minuteRef.value |
| | | if (!el) return |
| | | const hourRef = ref<HTMLElement | null>(null); |
| | | const minuteRef = ref<HTMLElement | null>(null); |
| | | |
| | | clearTimeout((el as any)._timer) |
| | | ;(el as any)._timer = setTimeout(() => { |
| | | const index = Math.round(el.scrollTop / 40) |
| | | if (type === 'hour') { |
| | | selectedHour.value = hours[index] |
| | | scrollTo(hourRef.value, index) |
| | | } else { |
| | | selectedMinute.value = minutes[index] |
| | | scrollTo(minuteRef.value, index) |
| | | } |
| | | updateModel() |
| | | }, 100) |
| | | } |
| | | const ITEM_REM = 0.4; |
| | | 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( |
| | | () => props.modelValue, |
| | | (newVal) => { |
| | | if (!newVal) return |
| | | const [h, m] = newVal.split(':').map(Number) |
| | | selectedHour.value = h |
| | | selectedMinute.value = m |
| | | nextTick(() => { |
| | | scrollTo(hourRef.value, h) |
| | | scrollTo(minuteRef.value, m) |
| | | }) |
| | | }, |
| | | { immediate: true } |
| | | ) |
| | | </script> |
| | | () => |
| | | `${selectedHour.value.toString().padStart(2, "0")}:${selectedMinute.value |
| | | .toString() |
| | | .padStart(2, "0")}`, |
| | | (val) => { |
| | | emit("update:modelValue", val); |
| | | } |
| | | ); |
| | | |
| | | <style scoped> |
| | | .time-picker { |
| | | display: flex; |
| | | justify-content: center; |
| | | align-items: center; |
| | | height: 200px; |
| | | background: #e6efff; |
| | | font-family: sans-serif; |
| | | function onScroll(type: "hour" | "minute") { |
| | | 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: 2rem; // 5 * 0.4rem 每个item高度0.4rem |
| | | overflow: hidden; |
| | | |
| | | .picker-column { |
| | | width: 60px; |
| | | height: 200px; |
| | | height: 2rem; |
| | | width: 0.9rem; |
| | | overflow-y: scroll; |
| | | scroll-snap-type: y mandatory; |
| | | -webkit-overflow-scrolling: touch; |
| | | text-align: center; |
| | | position: relative; |
| | | padding-top: 80px; |
| | | padding-bottom: 80px; |
| | | scrollbar-width: none; /* Firefox */ |
| | | -ms-overflow-style: none; /* IE 10+ */ |
| | | &::-webkit-scrollbar { |
| | | display: none; /* Chrome Safari */ |
| | | } |
| | | |
| | | .picker-item { |
| | | height: 40px; |
| | | line-height: 40px; |
| | | font-size: 14px; |
| | | color: #666; |
| | | height: 0.4rem; |
| | | line-height: 0.4rem; |
| | | text-align: center; |
| | | font-size: 0.24rem; |
| | | scroll-snap-align: center; |
| | | transition: all 0.2s; |
| | | transform: scale(0.8); |
| | | opacity: 0.5; |
| | | } |
| | | user-select: none; |
| | | cursor: pointer; |
| | | transition: color 0.3s ease, font-size 0.3s ease; |
| | | |
| | | .picker-item.active { |
| | | font-size: 24px; |
| | | font-weight: bold; |
| | | color: #333; |
| | | transform: scale(1.2); |
| | | opacity: 1; |
| | | |
| | | &.active { |
| | | font-size: 0.5rem; |
| | | font-weight: 700; |
| | | color: #111; |
| | | &.hours { |
| | | text-align: left; |
| | | } |
| | | &.minutes { |
| | | text-align: right; |
| | | } |
| | | } |
| | | &.medium { |
| | | font-size: 0.3rem; |
| | | color: #666; |
| | | &.hours { |
| | | text-align: left; |
| | | padding-left: 0.2rem; |
| | | } |
| | | &.minutes { |
| | | text-align: right; |
| | | padding-right: 0.2rem; |
| | | } |
| | | } |
| | | &.small { |
| | | font-size: 0.24rem; |
| | | color: #aaa; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .colon { |
| | | font-size: 24px; |
| | | margin: 0 10px; |
| | | font-size: 0.5rem; |
| | | font-weight: 600; |
| | | color: #444; |
| | | user-select: none; |
| | | } |
| | | } |
| | | </style> |