如何用原生JS实现类iOS/Edge的无限循环时分选择器
纯JavaScript实现iOS风格无限循环时分选择器
核心实现不需要复杂的环形DOM计算,用「多份数据拼接+滚动边界无感知校正」的方案就能做出完全无破绽的无限循环滚动效果,整体逻辑轻量,无任何第三方依赖。

也可以参考微软Edge浏览器<input type="time" />控件的交互效果:
核心实现原理
- 无限滚动效果:把0-23小时、0-59分钟的选项列表重复拼接3份,初始加载时直接定位到中间那组列表的对应位置;当用户滚动到第一组或者第三组的边界时,在滚动动画结束后瞬间(关闭CSS过渡)把位置校正回中间组相同数值的位置,整个跳转过程用户完全感知不到,视觉上就是无限循环的环形滚动。
- 交互适配:同时监听移动端触摸事件(
touchstart/touchmove/touchend)和PC端鼠标滚轮(wheel)事件,直接通过CSStransform: translateY控制列表位移,比修改scrollTop的性能更好,动画更流畅。 - 选中吸附:滚动结束后自动计算离中间选中参考线最近的选项位置,自动做回弹吸附,和iOS原生选择器的交互逻辑一致。
- 拟真效果:给选择器容器加透视属性,选项根据离中间选中线的距离调整透明度、缩放值,模拟滚轮的曲面视觉效果;如果要更逼真还可以加X轴旋转角度,还原原生选择器的3D弧度。
可直接运行的最简实现代码
<style> .picker-wrap { height: 200px; width: 120px; overflow: hidden; position: relative; perspective: 1000px; } .picker-scroll { position: absolute; width: 100%; top: 0; left: 0; } .picker-item { height: 40px; line-height: 40px; text-align: center; font-size: 18px; color: #333; opacity: 0.4; transform: scale(0.9); transition: all 0.2s ease; } .picker-item.selected { opacity: 1; transform: scale(1.1); font-weight: 500; } .picker-indicator { position: absolute; top: 80px; left: 0; right: 0; height: 40px; border-top: 1px solid #e5e5e5; border-bottom: 1px solid #e5e5e5; pointer-events: none; } </style> <!-- 小时选择器 --> <div class="picker-wrap" id="hourPicker"> <div class="picker-scroll"></div> <div class="picker-indicator"></div> </div> <script> class LoopPicker { constructor(container, data, onSelect) { this.container = container; this.scrollEl = container.querySelector('.picker-scroll'); this.data = data; this.itemHeight = 40; this.onSelect = onSelect; this.currentY = 0; this.init(); } init() { // 拼接3份数据作为滚动基础 const renderData = [...this.data, ...this.data, ...this.data]; this.scrollEl.innerHTML = renderData.map(val => `<div class="picker-item">${val.toString().padStart(2, '0')}</div>`).join(''); this.itemEls = this.scrollEl.querySelectorAll('.picker-item'); // 初始定位到中间组的起始位置 this.updatePosition(this.data.length * this.itemHeight, false); this.bindEvents(); } updatePosition(y, useAnimate = true) { this.scrollEl.style.transition = useAnimate ? 'transform 0.2s ease-out' : 'none'; this.currentY = y; this.scrollEl.style.transform = `translateY(${-y}px)`; // 计算当前选中项索引 const activeIndex = Math.round(y / this.itemHeight); this.itemEls.forEach((el, idx) => el.classList.toggle('selected', idx === activeIndex)); // 边界无感知校正 setTimeout(() => { if (activeIndex < this.data.length) { this.updatePosition(y + this.data.length * this.itemHeight, false); } if (activeIndex >= this.data.length * 2) { this.updatePosition(y - this.data.length * this.itemHeight, false); } }, useAnimate ? 200 : 0); // 返回真实选中值 const realVal = this.data[activeIndex % this.data.length]; this.onSelect?.(realVal); } bindEvents() { let startY, startScrollY; // 移动端触摸滚动 this.container.addEventListener('touchstart', e => { startY = e.touches[0].clientY; startScrollY = this.currentY; this.scrollEl.style.transition = 'none'; }); this.container.addEventListener('touchmove', e => { const delta = e.touches[0].clientY - startY; this.updatePosition(startScrollY - delta, false); }); this.container.addEventListener('touchend', () => { const targetY = Math.round(this.currentY / this.itemHeight) * this.itemHeight; this.updatePosition(targetY, true); }); // PC端鼠标滚轮 this.container.addEventListener('wheel', e => { e.preventDefault(); const step = e.deltaY > 0 ? this.itemHeight : -this.itemHeight; this.updatePosition(this.currentY + step, true); }, { passive: false }); } } // 初始化使用 const hourData = Array.from({length: 24}, (_, i) => i); const minuteData = Array.from({length: 60}, (_, i) => i); new LoopPicker(document.getElementById('hourPicker'), hourData, val => { console.log('选中小时:', val); }); // 分钟选择器复用LoopPicker类,传入minuteData初始化即可 </script>
实现注意事项
- 单个选项的高度必须固定,所有偏移、吸附、校正的计算都依赖固定的选项高度,高度不固定会出现错位、对不齐的问题。
- 边界位置校正一定要关闭CSS过渡动画,否则会出现肉眼可见的列表跳转,穿帮。
- 如果需要更贴近原生的3D滚轮效果,可以给每个选项根据和中间选中位置的距离差,添加
rotateX旋转属性,调整transform-origin位置即可实现更逼真的曲面滚轮效果。
内容的提问来源于stack exchange,提问作者Razvan Nuna
相关产品推荐
相关产品推荐

