You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用原生JS实现类iOS/Edge的无限循环时分选择器

纯JavaScript实现iOS风格无限循环时分选择器

核心实现不需要复杂的环形DOM计算,用「多份数据拼接+滚动边界无感知校正」的方案就能做出完全无破绽的无限循环滚动效果,整体逻辑轻量,无任何第三方依赖。

iOS风格时间选择器效果示例
也可以参考微软Edge浏览器<input type="time" />控件的交互效果:
Edge浏览器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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 00:09:31