Created
February 2, 2026 06:27
-
-
Save aaaa-zhen/dc83036a9319d050b69ab376bd11752f to your computer and use it in GitHub Desktop.
picker number
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> | |
| <title>Picker 教程 - 分步学习</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; | |
| background: #f5f5f7; | |
| min-height: 100vh; | |
| padding: 20px; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| .container { | |
| max-width: 600px; | |
| margin: 0 auto; | |
| } | |
| h1 { | |
| font-size: 28px; | |
| font-weight: 600; | |
| color: #1d1d1f; | |
| margin-bottom: 20px; | |
| text-align: center; | |
| } | |
| /* Tab Navigation */ | |
| .tabs { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 20px; | |
| background: #fff; | |
| padding: 8px; | |
| border-radius: 12px; | |
| } | |
| .tab { | |
| flex: 1; | |
| padding: 12px 16px; | |
| border: none; | |
| background: transparent; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: #86868b; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .tab.active { | |
| background: #007aff; | |
| color: #fff; | |
| } | |
| .tab:hover:not(.active) { | |
| background: #f0f0f0; | |
| } | |
| /* Demo Area */ | |
| .demo-area { | |
| background: #fff; | |
| border-radius: 16px; | |
| padding: 24px; | |
| margin-bottom: 16px; | |
| } | |
| .demo-title { | |
| font-size: 18px; | |
| font-weight: 600; | |
| color: #1d1d1f; | |
| margin-bottom: 8px; | |
| } | |
| .demo-desc { | |
| font-size: 14px; | |
| color: #86868b; | |
| margin-bottom: 20px; | |
| line-height: 1.5; | |
| } | |
| .picker-container { | |
| position: relative; | |
| width: 120px; | |
| height: 200px; | |
| margin: 0 auto 20px; | |
| background: #f5f5f7; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| } | |
| .picker-container::before { | |
| content: ''; | |
| position: absolute; | |
| top: 50%; | |
| left: 0; | |
| right: 0; | |
| height: 40px; | |
| transform: translateY(-50%); | |
| background: rgba(0,122,255,0.1); | |
| border-top: 1px solid rgba(0,122,255,0.3); | |
| border-bottom: 1px solid rgba(0,122,255,0.3); | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| .picker-wheel { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .picker-item { | |
| position: absolute; | |
| width: 100%; | |
| height: 40px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 22px; | |
| font-weight: 500; | |
| color: #1d1d1f; | |
| } | |
| /* Data Display */ | |
| .data-panel { | |
| background: #1d1d1f; | |
| border-radius: 12px; | |
| padding: 16px; | |
| margin-bottom: 16px; | |
| } | |
| .data-row { | |
| display: flex; | |
| justify-content: space-between; | |
| padding: 8px 0; | |
| border-bottom: 1px solid #333; | |
| font-size: 14px; | |
| } | |
| .data-row:last-child { | |
| border-bottom: none; | |
| } | |
| .data-label { | |
| color: #86868b; | |
| } | |
| .data-value { | |
| color: #30d158; | |
| font-family: 'SF Mono', monospace; | |
| font-weight: 600; | |
| } | |
| /* Code Display */ | |
| .code-area { | |
| background: #1d1d1f; | |
| border-radius: 16px; | |
| padding: 20px; | |
| overflow-x: auto; | |
| } | |
| .code-title { | |
| font-size: 14px; | |
| color: #86868b; | |
| margin-bottom: 12px; | |
| } | |
| pre { | |
| margin: 0; | |
| font-family: 'SF Mono', Monaco, monospace; | |
| font-size: 13px; | |
| line-height: 1.6; | |
| color: #fff; | |
| white-space: pre-wrap; | |
| } | |
| .comment { color: #6a9955; } | |
| .keyword { color: #569cd6; } | |
| .function { color: #dcdcaa; } | |
| .number { color: #b5cea8; } | |
| .string { color: #ce9178; } | |
| .property { color: #9cdcfe; } | |
| /* Step specific styles */ | |
| .step-content { | |
| display: none; | |
| } | |
| .step-content.active { | |
| display: block; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Picker 教程</h1> | |
| <div class="tabs"> | |
| <button class="tab active" data-step="1">第一步<br><small>拖拽</small></button> | |
| <button class="tab" data-step="2">第二步<br><small>速度</small></button> | |
| <button class="tab" data-step="3">第三步<br><small>弹簧</small></button> | |
| <button class="tab" data-step="4">第四步<br><small>对齐</small></button> | |
| </div> | |
| <!-- Step 1: Drag --> | |
| <div class="step-content active" id="step1"> | |
| <div class="demo-area"> | |
| <div class="demo-title">第一步:基础拖拽</div> | |
| <div class="demo-desc"> | |
| 学习如何追踪拖拽手势。<br> | |
| 试着上下拖动数字 — 松手后会停在你释放的位置。 | |
| </div> | |
| <div class="picker-container" id="picker1"> | |
| <div class="picker-wheel" id="wheel1"></div> | |
| </div> | |
| </div> | |
| <div class="data-panel"> | |
| <div class="data-row"> | |
| <span class="data-label">是否拖拽中</span> | |
| <span class="data-value" id="data1-dragging">false</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">拖拽起点 Y</span> | |
| <span class="data-value" id="data1-startY">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">当前位置</span> | |
| <span class="data-value" id="data1-pos">0</span> | |
| </div> | |
| </div> | |
| <div class="code-area"> | |
| <div class="code-title">// 第一步:基础拖拽</div> | |
| <pre><span class="keyword">let</span> pos = <span class="number">0</span> | |
| <span class="keyword">let</span> isDragging = <span class="keyword">false</span> | |
| <span class="keyword">let</span> dragStartY = <span class="number">0</span> | |
| <span class="keyword">let</span> dragStartPos = <span class="number">0</span> | |
| <span class="comment">// 按下:记录起点</span> | |
| picker.<span class="function">addEventListener</span>(<span class="string">'mousedown'</span>, e => { | |
| isDragging = <span class="keyword">true</span> | |
| dragStartY = e.<span class="property">pageY</span> | |
| dragStartPos = pos | |
| }) | |
| <span class="comment">// 移动:计算偏移量</span> | |
| window.<span class="function">addEventListener</span>(<span class="string">'mousemove'</span>, e => { | |
| <span class="keyword">if</span> (!isDragging) <span class="keyword">return</span> | |
| pos = dragStartPos + (dragStartY - e.<span class="property">pageY</span>) | |
| <span class="function">render</span>() | |
| }) | |
| <span class="comment">// 松手:停止拖拽</span> | |
| window.<span class="function">addEventListener</span>(<span class="string">'mouseup'</span>, () => { | |
| isDragging = <span class="keyword">false</span> | |
| })</pre> | |
| </div> | |
| </div> | |
| <!-- Step 2: Velocity --> | |
| <div class="step-content" id="step2"> | |
| <div class="demo-area"> | |
| <div class="demo-title">第二步:获取速度</div> | |
| <div class="demo-desc"> | |
| 记录指针历史来计算释放时的速度。<br> | |
| 试着快速滑动 — 观察松手时的速度值! | |
| </div> | |
| <div class="picker-container" id="picker2"> | |
| <div class="picker-wheel" id="wheel2"></div> | |
| </div> | |
| </div> | |
| <div class="data-panel"> | |
| <div class="data-row"> | |
| <span class="data-label">当前位置</span> | |
| <span class="data-value" id="data2-pos">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">速度</span> | |
| <span class="data-value" id="data2-velocity">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">历史记录数</span> | |
| <span class="data-value" id="data2-history">0</span> | |
| </div> | |
| </div> | |
| <div class="code-area"> | |
| <div class="code-title">// 第二步:计算速度</div> | |
| <pre><span class="keyword">let</span> pointerHistory = [] | |
| <span class="comment">// 移动时:记录时间和位置</span> | |
| window.<span class="function">addEventListener</span>(<span class="string">'mousemove'</span>, e => { | |
| <span class="keyword">if</span> (!isDragging) <span class="keyword">return</span> | |
| pointerHistory.<span class="function">push</span>({ | |
| y: e.<span class="property">pageY</span>, | |
| time: performance.<span class="function">now</span>() | |
| }) | |
| <span class="comment">// 只保留最近 10 个点</span> | |
| <span class="keyword">if</span> (pointerHistory.<span class="property">length</span> > <span class="number">10</span>) { | |
| pointerHistory.<span class="function">shift</span>() | |
| } | |
| }) | |
| <span class="comment">// 松手时:计算速度</span> | |
| window.<span class="function">addEventListener</span>(<span class="string">'mouseup'</span>, () => { | |
| <span class="keyword">const</span> now = performance.<span class="function">now</span>() | |
| <span class="keyword">const</span> recent = pointerHistory[<span class="number">0</span>] | |
| <span class="keyword">const</span> last = pointerHistory.<span class="function">at</span>(-<span class="number">1</span>) | |
| <span class="keyword">const</span> dt = now - recent.<span class="property">time</span> | |
| <span class="keyword">const</span> dy = recent.<span class="property">y</span> - last.<span class="property">y</span> | |
| <span class="comment">// 速度 = 距离 / 时间 (px/s)</span> | |
| velocity = dy / dt * <span class="number">1000</span> | |
| })</pre> | |
| </div> | |
| </div> | |
| <!-- Step 3: Spring --> | |
| <div class="step-content" id="step3"> | |
| <div class="demo-area"> | |
| <div class="demo-title">第三步:弹簧物理</div> | |
| <div class="demo-desc"> | |
| 使用弹簧物理驱动到目标位置。<br> | |
| 松手后会弹簧式地滑动到最近的格子。调节 k(刚度)和 b(阻尼)看效果变化。 | |
| </div> | |
| <div class="picker-container" id="picker3"> | |
| <div class="picker-wheel" id="wheel3"></div> | |
| </div> | |
| <div style="display: flex; gap: 16px; margin-top: 16px;"> | |
| <div style="flex: 1;"> | |
| <label style="font-size: 13px; color: #86868b;">刚度 (k): <span id="k3-value">200</span></label> | |
| <input type="range" id="k3-slider" min="50" max="500" value="200" style="width: 100%;"> | |
| </div> | |
| <div style="flex: 1;"> | |
| <label style="font-size: 13px; color: #86868b;">阻尼 (b): <span id="b3-value">28</span></label> | |
| <input type="range" id="b3-slider" min="5" max="60" value="28" style="width: 100%;"> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="data-panel"> | |
| <div class="data-row"> | |
| <span class="data-label">当前位置</span> | |
| <span class="data-value" id="data3-pos">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">速度</span> | |
| <span class="data-value" id="data3-velocity">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">目标位置</span> | |
| <span class="data-value" id="data3-dest">0</span> | |
| </div> | |
| </div> | |
| <div class="code-area"> | |
| <div class="code-title">// 第三步:弹簧物理</div> | |
| <pre><span class="keyword">function</span> <span class="function">springStep</span>(spring) { | |
| <span class="keyword">const</span> dt = <span class="number">0.004</span> <span class="comment">// 每步 4ms</span> | |
| <span class="comment">// 弹力:把位置拉向目标</span> | |
| <span class="keyword">const</span> Fspring = -spring.<span class="property">k</span> * (spring.<span class="property">pos</span> - spring.<span class="property">dest</span>) | |
| <span class="comment">// 阻尼力:消耗速度</span> | |
| <span class="keyword">const</span> Fdamper = -spring.<span class="property">b</span> * spring.<span class="property">v</span> | |
| <span class="comment">// 更新速度和位置</span> | |
| spring.<span class="property">v</span> += (Fspring + Fdamper) * dt | |
| spring.<span class="property">pos</span> += spring.<span class="property">v</span> * dt | |
| } | |
| <span class="comment">// 松手时:设置目标为最近的格子</span> | |
| spring.<span class="property">dest</span> = Math.<span class="function">round</span>(pos / ITEM_HEIGHT) * ITEM_HEIGHT | |
| spring.<span class="property">v</span> = velocity <span class="comment">// 保持 fling 的速度</span></pre> | |
| </div> | |
| </div> | |
| <!-- Step 4: Snap --> | |
| <div class="step-content" id="step4"> | |
| <div class="demo-area"> | |
| <div class="demo-title">第四步:智能对齐</div> | |
| <div class="demo-desc"> | |
| 预测惯性终点,然后对齐到最近的格子。<br> | |
| 快速滑动可以跨越多格,慢滑只移动一格! | |
| </div> | |
| <div class="picker-container" id="picker4"> | |
| <div class="picker-wheel" id="wheel4"></div> | |
| </div> | |
| </div> | |
| <div class="data-panel"> | |
| <div class="data-row"> | |
| <span class="data-label">当前位置</span> | |
| <span class="data-value" id="data4-pos">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">速度</span> | |
| <span class="data-value" id="data4-velocity">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">预测终点</span> | |
| <span class="data-value" id="data4-predict">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">对齐后目标</span> | |
| <span class="data-value" id="data4-snap">0</span> | |
| </div> | |
| <div class="data-row"> | |
| <span class="data-label">选中值</span> | |
| <span class="data-value" id="data4-value">0</span> | |
| </div> | |
| </div> | |
| <div class="code-area"> | |
| <div class="code-title">// 第四步:预测 + 对齐</div> | |
| <pre><span class="keyword">const</span> ITEM_HEIGHT = <span class="number">40</span> | |
| <span class="keyword">const</span> FRICTION = <span class="number">2</span> | |
| <span class="keyword">function</span> <span class="function">calculateSnapTarget</span>(pos, velocity) { | |
| <span class="comment">// 1. 预测自然衰减会停在哪里</span> | |
| <span class="keyword">const</span> decayTarget = pos + velocity / FRICTION | |
| <span class="comment">// 2. 对齐到最近的格子</span> | |
| <span class="keyword">const</span> snapped = Math.<span class="function">round</span>(decayTarget / ITEM_HEIGHT) * ITEM_HEIGHT | |
| <span class="keyword">return</span> snapped | |
| } | |
| <span class="comment">// 松手时:</span> | |
| <span class="keyword">const</span> target = <span class="function">calculateSnapTarget</span>(spring.<span class="property">pos</span>, velocity) | |
| spring.<span class="property">dest</span> = target <span class="comment">// 弹簧会动画到这里</span> | |
| spring.<span class="property">v</span> = velocity <span class="comment">// 保持惯性</span> | |
| <span class="comment">// 弹簧物理会处理平滑过渡!</span></pre> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| 'use strict' | |
| // ============================================ | |
| // 常量 | |
| // ============================================ | |
| const ITEM_HEIGHT = 40 | |
| const CENTER_Y = 80 | |
| const MIN_VALUE = 0 | |
| const MAX_VALUE = 60 | |
| // ============================================ | |
| // Tab 切换 | |
| // ============================================ | |
| const tabs = document.querySelectorAll('.tab') | |
| const steps = document.querySelectorAll('.step-content') | |
| tabs.forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| const step = tab.dataset.step | |
| tabs.forEach(t => t.classList.remove('active')) | |
| steps.forEach(s => s.classList.remove('active')) | |
| tab.classList.add('active') | |
| document.getElementById('step' + step).classList.add('active') | |
| }) | |
| }) | |
| // ============================================ | |
| // 辅助函数:创建数字元素 | |
| // ============================================ | |
| function createItems(wheel, count = 11) { | |
| const items = [] | |
| for (let i = 0; i < count; i++) { | |
| const item = document.createElement('div') | |
| item.className = 'picker-item' | |
| wheel.appendChild(item) | |
| items.push(item) | |
| } | |
| return items | |
| } | |
| // ============================================ | |
| // 辅助函数:限制范围 | |
| // ============================================ | |
| function clamp(value, min, max) { | |
| return Math.max(min, Math.min(max, value)) | |
| } | |
| // ============================================ | |
| // 第一步:基础拖拽 | |
| // ============================================ | |
| ;(function step1() { | |
| const picker = document.getElementById('picker1') | |
| const wheel = document.getElementById('wheel1') | |
| const items = createItems(wheel) | |
| let pos = 0 | |
| let isDragging = false | |
| let dragStartY = 0 | |
| let dragStartPos = 0 | |
| function render() { | |
| const centerIndex = pos / ITEM_HEIGHT | |
| const startIndex = Math.floor(centerIndex) - 5 | |
| for (let i = 0; i < items.length; i++) { | |
| const itemIndex = startIndex + i | |
| const offset = itemIndex - centerIndex | |
| const y = CENTER_Y + offset * ITEM_HEIGHT | |
| // 只显示 0-60 | |
| if (itemIndex < MIN_VALUE || itemIndex > MAX_VALUE) { | |
| items[i].style.visibility = 'hidden' | |
| } else { | |
| items[i].style.visibility = 'visible' | |
| items[i].textContent = itemIndex | |
| } | |
| items[i].style.transform = `translateY(${y}px)` | |
| items[i].style.opacity = Math.abs(offset) < 0.5 ? 1 : 0.4 | |
| } | |
| document.getElementById('data1-pos').textContent = Math.round(pos) | |
| } | |
| picker.addEventListener('mousedown', e => { | |
| isDragging = true | |
| dragStartY = e.pageY | |
| dragStartPos = pos | |
| document.getElementById('data1-dragging').textContent = 'true' | |
| document.getElementById('data1-startY').textContent = Math.round(dragStartY) | |
| }) | |
| picker.addEventListener('touchstart', e => { | |
| isDragging = true | |
| dragStartY = e.touches[0].pageY | |
| dragStartPos = pos | |
| document.getElementById('data1-dragging').textContent = 'true' | |
| document.getElementById('data1-startY').textContent = Math.round(dragStartY) | |
| }, { passive: true }) | |
| window.addEventListener('mousemove', e => { | |
| if (!isDragging) return | |
| pos = clamp(dragStartPos + (dragStartY - e.pageY), MIN_VALUE * ITEM_HEIGHT, MAX_VALUE * ITEM_HEIGHT) | |
| render() | |
| }) | |
| window.addEventListener('touchmove', e => { | |
| if (!isDragging) return | |
| pos = clamp(dragStartPos + (dragStartY - e.touches[0].pageY), MIN_VALUE * ITEM_HEIGHT, MAX_VALUE * ITEM_HEIGHT) | |
| render() | |
| }, { passive: true }) | |
| window.addEventListener('mouseup', () => { | |
| if (!isDragging) return | |
| isDragging = false | |
| document.getElementById('data1-dragging').textContent = 'false' | |
| }) | |
| window.addEventListener('touchend', () => { | |
| if (!isDragging) return | |
| isDragging = false | |
| document.getElementById('data1-dragging').textContent = 'false' | |
| }) | |
| render() | |
| })() | |
| // ============================================ | |
| // 第二步:获取速度 | |
| // ============================================ | |
| ;(function step2() { | |
| const picker = document.getElementById('picker2') | |
| const wheel = document.getElementById('wheel2') | |
| const items = createItems(wheel) | |
| let pos = 0 | |
| let velocity = 0 | |
| let isDragging = false | |
| let dragStartY = 0 | |
| let dragStartPos = 0 | |
| let pointerHistory = [] | |
| function render() { | |
| const centerIndex = pos / ITEM_HEIGHT | |
| const startIndex = Math.floor(centerIndex) - 5 | |
| for (let i = 0; i < items.length; i++) { | |
| const itemIndex = startIndex + i | |
| const offset = itemIndex - centerIndex | |
| const y = CENTER_Y + offset * ITEM_HEIGHT | |
| if (itemIndex < MIN_VALUE || itemIndex > MAX_VALUE) { | |
| items[i].style.visibility = 'hidden' | |
| } else { | |
| items[i].style.visibility = 'visible' | |
| items[i].textContent = itemIndex | |
| } | |
| items[i].style.transform = `translateY(${y}px)` | |
| items[i].style.opacity = Math.abs(offset) < 0.5 ? 1 : 0.4 | |
| } | |
| document.getElementById('data2-pos').textContent = Math.round(pos) | |
| document.getElementById('data2-history').textContent = pointerHistory.length | |
| } | |
| function handleDown(y) { | |
| isDragging = true | |
| dragStartY = y | |
| dragStartPos = pos | |
| pointerHistory = [{ y, time: performance.now() }] | |
| } | |
| function handleMove(y) { | |
| if (!isDragging) return | |
| pos = clamp(dragStartPos + (dragStartY - y), MIN_VALUE * ITEM_HEIGHT, MAX_VALUE * ITEM_HEIGHT) | |
| pointerHistory.push({ y, time: performance.now() }) | |
| if (pointerHistory.length > 10) pointerHistory.shift() | |
| render() | |
| } | |
| function handleUp() { | |
| if (!isDragging) return | |
| isDragging = false | |
| const now = performance.now() | |
| if (pointerHistory.length >= 2) { | |
| let i = pointerHistory.length - 1 | |
| while (i > 0 && now - pointerHistory[i].time < 100) i-- | |
| const dt = now - pointerHistory[i].time | |
| if (dt > 0) { | |
| velocity = (pointerHistory[i].y - pointerHistory.at(-1).y) / dt * 1000 | |
| } | |
| } | |
| document.getElementById('data2-velocity').textContent = Math.round(velocity) | |
| // 简单的惯性衰减动画 | |
| const decay = () => { | |
| velocity *= 0.95 | |
| pos = clamp(pos + velocity * 0.016, MIN_VALUE * ITEM_HEIGHT, MAX_VALUE * ITEM_HEIGHT) | |
| render() | |
| if (Math.abs(velocity) > 1) { | |
| requestAnimationFrame(decay) | |
| } | |
| } | |
| decay() | |
| } | |
| picker.addEventListener('mousedown', e => handleDown(e.pageY)) | |
| picker.addEventListener('touchstart', e => handleDown(e.touches[0].pageY), { passive: true }) | |
| window.addEventListener('mousemove', e => handleMove(e.pageY)) | |
| window.addEventListener('touchmove', e => handleMove(e.touches[0].pageY), { passive: true }) | |
| window.addEventListener('mouseup', handleUp) | |
| window.addEventListener('touchend', handleUp) | |
| render() | |
| })() | |
| // ============================================ | |
| // 第三步:弹簧物理 | |
| // ============================================ | |
| ;(function step3() { | |
| const picker = document.getElementById('picker3') | |
| const wheel = document.getElementById('wheel3') | |
| const items = createItems(wheel) | |
| const MS_PER_STEP = 4 | |
| let spring = { pos: 0, dest: 0, v: 0, k: 200, b: 28 } | |
| let isDragging = false | |
| let dragStartY = 0 | |
| let dragStartPos = 0 | |
| let pointerHistory = [] | |
| let animating = false | |
| let lastTime = null | |
| function springStep() { | |
| const dt = MS_PER_STEP / 1000 | |
| const Fspring = -spring.k * (spring.pos - spring.dest) | |
| const Fdamper = -spring.b * spring.v | |
| spring.v += (Fspring + Fdamper) * dt | |
| spring.pos += spring.v * dt | |
| } | |
| function render(now) { | |
| if (lastTime !== null && !isDragging) { | |
| const steps = Math.floor((now - lastTime) / MS_PER_STEP) | |
| for (let i = 0; i < steps; i++) springStep() | |
| lastTime += steps * MS_PER_STEP | |
| } | |
| const centerIndex = spring.pos / ITEM_HEIGHT | |
| const startIndex = Math.floor(centerIndex) - 5 | |
| for (let i = 0; i < items.length; i++) { | |
| const itemIndex = startIndex + i | |
| const offset = itemIndex - centerIndex | |
| const y = CENTER_Y + offset * ITEM_HEIGHT | |
| if (itemIndex < MIN_VALUE || itemIndex > MAX_VALUE) { | |
| items[i].style.visibility = 'hidden' | |
| } else { | |
| items[i].style.visibility = 'visible' | |
| items[i].textContent = itemIndex | |
| } | |
| items[i].style.transform = `translateY(${y}px)` | |
| items[i].style.opacity = Math.abs(offset) < 0.5 ? 1 : 0.4 | |
| } | |
| document.getElementById('data3-pos').textContent = Math.round(spring.pos) | |
| document.getElementById('data3-velocity').textContent = Math.round(spring.v) | |
| document.getElementById('data3-dest').textContent = Math.round(spring.dest) | |
| if (animating && (Math.abs(spring.v) > 0.5 || Math.abs(spring.pos - spring.dest) > 0.5)) { | |
| requestAnimationFrame(render) | |
| } else if (animating) { | |
| animating = false | |
| spring.pos = spring.dest | |
| spring.v = 0 | |
| render(now) | |
| } | |
| } | |
| function handleDown(y) { | |
| isDragging = true | |
| animating = false | |
| dragStartY = y | |
| dragStartPos = spring.pos | |
| spring.v = 0 | |
| pointerHistory = [{ y, time: performance.now() }] | |
| } | |
| function handleMove(y) { | |
| if (!isDragging) return | |
| spring.pos = spring.dest = clamp(dragStartPos + (dragStartY - y), MIN_VALUE * ITEM_HEIGHT, MAX_VALUE * ITEM_HEIGHT) | |
| pointerHistory.push({ y, time: performance.now() }) | |
| if (pointerHistory.length > 10) pointerHistory.shift() | |
| render(performance.now()) | |
| } | |
| function handleUp() { | |
| if (!isDragging) return | |
| isDragging = false | |
| const now = performance.now() | |
| let velocity = 0 | |
| if (pointerHistory.length >= 2) { | |
| let i = pointerHistory.length - 1 | |
| while (i > 0 && now - pointerHistory[i].time < 100) i-- | |
| const dt = now - pointerHistory[i].time | |
| if (dt > 0) { | |
| velocity = (pointerHistory[i].y - pointerHistory.at(-1).y) / dt * 1000 | |
| } | |
| } | |
| // 目标 = 当前位置对齐到最近的格子(限制在 0-60 范围内) | |
| const nearestIndex = clamp(Math.round(spring.pos / ITEM_HEIGHT), MIN_VALUE, MAX_VALUE) | |
| spring.dest = nearestIndex * ITEM_HEIGHT | |
| spring.v = velocity | |
| lastTime = now | |
| animating = true | |
| requestAnimationFrame(render) | |
| } | |
| picker.addEventListener('mousedown', e => handleDown(e.pageY)) | |
| picker.addEventListener('touchstart', e => handleDown(e.touches[0].pageY), { passive: true }) | |
| window.addEventListener('mousemove', e => handleMove(e.pageY)) | |
| window.addEventListener('touchmove', e => handleMove(e.touches[0].pageY), { passive: true }) | |
| window.addEventListener('mouseup', handleUp) | |
| window.addEventListener('touchend', handleUp) | |
| // 滑块 | |
| document.getElementById('k3-slider').addEventListener('input', e => { | |
| spring.k = +e.target.value | |
| document.getElementById('k3-value').textContent = e.target.value | |
| }) | |
| document.getElementById('b3-slider').addEventListener('input', e => { | |
| spring.b = +e.target.value | |
| document.getElementById('b3-value').textContent = e.target.value | |
| }) | |
| render(performance.now()) | |
| })() | |
| // ============================================ | |
| // 第四步:智能对齐 | |
| // ============================================ | |
| ;(function step4() { | |
| const picker = document.getElementById('picker4') | |
| const wheel = document.getElementById('wheel4') | |
| const items = createItems(wheel) | |
| const MS_PER_STEP = 4 | |
| const FRICTION = 2 | |
| let spring = { pos: 0, dest: 0, v: 0, k: 200, b: 28 } | |
| let isDragging = false | |
| let dragStartY = 0 | |
| let dragStartPos = 0 | |
| let pointerHistory = [] | |
| let animating = false | |
| let lastTime = null | |
| function springStep() { | |
| const dt = MS_PER_STEP / 1000 | |
| const Fspring = -spring.k * (spring.pos - spring.dest) | |
| const Fdamper = -spring.b * spring.v | |
| spring.v += (Fspring + Fdamper) * dt | |
| spring.pos += spring.v * dt | |
| } | |
| function calculateSnapTarget(pos, velocity) { | |
| // 预测自然衰减终点 | |
| const decayTarget = pos + velocity / FRICTION | |
| document.getElementById('data4-predict').textContent = Math.round(decayTarget) | |
| // 对齐到最近格子,并限制范围 | |
| const snappedIndex = clamp(Math.round(decayTarget / ITEM_HEIGHT), MIN_VALUE, MAX_VALUE) | |
| const snapped = snappedIndex * ITEM_HEIGHT | |
| document.getElementById('data4-snap').textContent = snappedIndex | |
| return snapped | |
| } | |
| function render(now) { | |
| if (lastTime !== null && !isDragging) { | |
| const steps = Math.floor((now - lastTime) / MS_PER_STEP) | |
| for (let i = 0; i < steps; i++) springStep() | |
| lastTime += steps * MS_PER_STEP | |
| } | |
| const centerIndex = spring.pos / ITEM_HEIGHT | |
| const startIndex = Math.floor(centerIndex) - 5 | |
| for (let i = 0; i < items.length; i++) { | |
| const itemIndex = startIndex + i | |
| const offset = itemIndex - centerIndex | |
| const y = CENTER_Y + offset * ITEM_HEIGHT | |
| if (itemIndex < MIN_VALUE || itemIndex > MAX_VALUE) { | |
| items[i].style.visibility = 'hidden' | |
| } else { | |
| items[i].style.visibility = 'visible' | |
| items[i].textContent = itemIndex | |
| } | |
| items[i].style.transform = `translateY(${y}px)` | |
| items[i].style.opacity = Math.abs(offset) < 0.5 ? 1 : 0.4 | |
| } | |
| document.getElementById('data4-pos').textContent = Math.round(spring.pos) | |
| document.getElementById('data4-velocity').textContent = Math.round(spring.v) | |
| document.getElementById('data4-value').textContent = clamp(Math.round(spring.pos / ITEM_HEIGHT), MIN_VALUE, MAX_VALUE) | |
| if (animating && (Math.abs(spring.v) > 0.5 || Math.abs(spring.pos - spring.dest) > 0.5)) { | |
| requestAnimationFrame(render) | |
| } else if (animating) { | |
| animating = false | |
| spring.pos = spring.dest | |
| spring.v = 0 | |
| render(now) | |
| } | |
| } | |
| function handleDown(y) { | |
| isDragging = true | |
| animating = false | |
| dragStartY = y | |
| dragStartPos = spring.pos | |
| spring.v = 0 | |
| pointerHistory = [{ y, time: performance.now() }] | |
| } | |
| function handleMove(y) { | |
| if (!isDragging) return | |
| spring.pos = spring.dest = clamp(dragStartPos + (dragStartY - y), MIN_VALUE * ITEM_HEIGHT, MAX_VALUE * ITEM_HEIGHT) | |
| pointerHistory.push({ y, time: performance.now() }) | |
| if (pointerHistory.length > 10) pointerHistory.shift() | |
| render(performance.now()) | |
| } | |
| function handleUp() { | |
| if (!isDragging) return | |
| isDragging = false | |
| const now = performance.now() | |
| let velocity = 0 | |
| if (pointerHistory.length >= 2) { | |
| let i = pointerHistory.length - 1 | |
| while (i > 0 && now - pointerHistory[i].time < 100) i-- | |
| const dt = now - pointerHistory[i].time | |
| if (dt > 0) { | |
| velocity = (pointerHistory[i].y - pointerHistory.at(-1).y) / dt * 1000 | |
| } | |
| } | |
| document.getElementById('data4-velocity').textContent = Math.round(velocity) | |
| spring.dest = calculateSnapTarget(spring.pos, velocity) | |
| spring.v = velocity | |
| lastTime = now | |
| animating = true | |
| requestAnimationFrame(render) | |
| } | |
| picker.addEventListener('mousedown', e => handleDown(e.pageY)) | |
| picker.addEventListener('touchstart', e => handleDown(e.touches[0].pageY), { passive: true }) | |
| window.addEventListener('mousemove', e => handleMove(e.pageY)) | |
| window.addEventListener('touchmove', e => handleMove(e.touches[0].pageY), { passive: true }) | |
| window.addEventListener('mouseup', handleUp) | |
| window.addEventListener('touchend', handleUp) | |
| render(performance.now()) | |
| })() | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment