原生 JS 实现移动端 Picker 组件

前端精髓

共 27714字,需浏览 56分钟

 · 2024-04-11


Picker 是指提供多个选项集合供用户选择其中一项的控件。Picker 展示区域有限,部分选项会被隐藏,最好是当用户对所有选项都比较熟悉、有预期的时候,才使用 Picker。

<!DOCTYPE html><html lang="en">  <head>    <meta charset="UTF-8" />    <meta name="viewport" content="width=device-width, initial-scale=1.0" />    <meta http-equiv="X-UA-Compatible" content="ie=edge" />    <title>picker</title>    <style>      * {        margin: 0;        padding: 0;      }      .scroller-component {        display: block;        position: relative;        height: 238px;        overflow: hidden;        width: 100%;      }
.scroller-content { position: absolute; left: 0; top: 0; width: 100%; z-index: 1; }
.scroller-mask { position: absolute; left: 0; top: 0; height: 100%; margin: 0 auto; width: 100%; z-index: 3; transform: translateZ(0px); background-image: linear-gradient( to bottom, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.6) ), linear-gradient( to top, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.6) ); background-position: top, bottom; background-size: 100% 102px; background-repeat: no-repeat; }
.scroller-item { text-align: center; font-size: 16px; height: 34px; line-height: 34px; color: #000; }
.scroller-indicator { width: 100%; height: 34px; position: absolute; left: 0; top: 102px; z-index: 3; background-image: linear-gradient( to bottom, #d0d0d0, #d0d0d0, transparent, transparent ), linear-gradient(to top, #d0d0d0, #d0d0d0, transparent, transparent); background-position: top, bottom; background-size: 100% 1px; background-repeat: no-repeat; }
.scroller-item { line-clamp: 1; -webkit-line-clamp: 1; overflow: hidden; text-overflow: ellipsis; }</style> </head>
<body> <div class="scroller-component" data-role="component"> <div class="scroller-mask" data-role="mask"></div> <div class="scroller-indicator" data-role="indicator"></div> <div class="scroller-content" data-role="content"> <div class="scroller-item" data-value="1">1</div> <div class="scroller-item" data-value="2">2</div> <div class="scroller-item" data-value="3">3</div> <div class="scroller-item" data-value="4">4</div> <div class="scroller-item" data-value="5">5</div> <div class="scroller-item" data-value="6">6</div> <div class="scroller-item" data-value="7">7</div> <div class="scroller-item" data-value="8">8</div> <div class="scroller-item" data-value="9">9</div> <div class="scroller-item" data-value="10">10</div> <div class="scroller-item" data-value="11">11</div> <div class="scroller-item" data-value="12">12</div> <div class="scroller-item" data-value="13">13</div> <div class="scroller-item" data-value="14">14</div> <div class="scroller-item" data-value="15">15</div> <div class="scroller-item" data-value="16">16</div> <div class="scroller-item" data-value="17">17</div> <div class="scroller-item" data-value="18">18</div> <div class="scroller-item" data-value="19">19</div> <div class="scroller-item" data-value="20">20</div> </div> </div> <script> let running = {}; // 运行 let counter = 1; // 计时器 let desiredFrames = 60; // 每秒多少帧 let millisecondsPerSecond = 1000; // 每秒的毫秒数
const Animate = { // 停止动画 stop(id) { var cleared = running[id] != null; if (cleared) { running[id] = null; } return cleared; },
// 判断给定的动画是否还在运行 isRunning(id) { return running[id] != null; }, start( stepCallback, verifyCallback, completedCallback, duration, easingMethod, root ) { let start = Date.now(); let percent = 0; // 百分比 let id = counter++; let dropCounter = 0;
let step = function () { let now = Date.now();
if (!running[id] || (verifyCallback && !verifyCallback(id))) { running[id] = null; completedCallback && completedCallback( desiredFrames - dropCounter / ((now - start) / millisecondsPerSecond), id, false ); return; }
if (duration) { percent = (now - start) / duration; if (percent > 1) { percent = 1; } } let value = easingMethod ? easingMethod(percent) : percent; if (percent !== 1 && (!verifyCallback || verifyCallback(id))) { stepCallback(value); window.requestAnimationFrame(step); } };
running[id] = true; window.requestAnimationFrame(step); return id; }, };</script> <script> let component = document.querySelector("[data-role=component]"); // 插件容器 let content = component.querySelector("[data-role=content]"); // 内容容器 let indicator = component.querySelector("[data-role=indicator]"); // 正确位置实线
let __startTouchTop = 0; let __scrollTop = 0; let __maxScrollTop = component.clientHeight / 2; // 滚动最大值 let __minScrollTop = -(content.offsetHeight - __maxScrollTop); // 滚动最小值 let __isAnimating = false; // 是否开启动画
let __lastTouchMove = 0; // 最后滚动时间记录 let __positions = []; // 记录位置和时间
let __deceleratingMove = 0; // 减速运动速度 let __isDecelerating = false; // 是否开启减速状态
let __itemHeight = parseFloat(window.getComputedStyle(indicator).height);
// 开始快后来慢的渐变曲线 let easeOutCubic = (pos) => { return Math.pow(pos - 1, 3) + 1; }; // 以满足开始和结束的动画 let easeInOutCubic = (pos) => { if ((pos /= 0.5) < 1) { return 0.5 * Math.pow(pos, 3); } return 0.5 * (Math.pow(pos - 2, 3) + 2); }; let __callback = (top) => { const distance = top; content.style.transform = "translate3d(0, " + distance + "px, 0)"; }; let __publish = (top, animationDuration) => { if (animationDuration) { let oldTop = __scrollTop; let diffTop = top - oldTop; let wasAnimating = __isAnimating;
let step = function (percent) { __scrollTop = oldTop + diffTop * percent; __callback(__scrollTop); }; let verify = function (id) { return __isAnimating === id; }; let completed = function ( renderedFramesPerSecond, animationId, wasFinished) { if (animationId === __isAnimating) { __isAnimating = false; } }; __isAnimating = Animate.start( step, verify, completed, animationDuration, wasAnimating ? easeOutCubic : easeInOutCubic ); } else { __scrollTop = top; __callback(top); } }; // 滚动到正确位置的方法 let __scrollTo = (top) => { top = Math.round((top / __itemHeight).toFixed(5)) * __itemHeight; let newTop = Math.max(Math.min(__maxScrollTop, top), __minScrollTop); if (top !== newTop) { if (newTop >= __maxScrollTop) { top = newTop - __itemHeight / 2; } else { top = newTop + __itemHeight / 2; } } __publish(top, 250); }; // 开始减速动画 let __startDeceleration = () => { let step = () => { let scrollTop = __scrollTop + __deceleratingMove; let scrollTopFixed = Math.max( Math.min(__maxScrollTop, scrollTop), __minScrollTop ); // 不小于最小值,不大于最大值 if (scrollTopFixed !== scrollTop) { scrollTop = scrollTopFixed; __deceleratingMove = 0; } if (Math.abs(__deceleratingMove) <= 1) { if (Math.abs(scrollTop % __itemHeight) < 1) { __deceleratingMove = 0; } } else { __deceleratingMove *= 0.95; } __publish(scrollTop); }; let minVelocityToKeepDecelerating = 0.5; let verify = () => { // 保持减速运行需要多少速度 let shouldContinue = Math.abs(__deceleratingMove) >= minVelocityToKeepDecelerating; return shouldContinue; }; let completed = function ( renderedFramesPerSecond, animationId, wasFinished) { __isDecelerating = false; if (__scrollTop <= __minScrollTop || __scrollTop >= __maxScrollTop) { __scrollTo(__scrollTop); return; } }; __isDecelerating = Animate.start(step, verify, completed); }; let touchStartHandler = (e) => { e.preventDefault(); const target = e.touches ? e.touches[0] : e; __startTouchTop = target.pageY; }; let touchMoveHandler = (e) => { const target = e.touches ? e.touches[0] : e; let currentTouchTop = target.pageY; let moveY = currentTouchTop - __startTouchTop; let scrollTop = __scrollTop; scrollTop = scrollTop + moveY; if (scrollTop > __maxScrollTop || scrollTop < __minScrollTop) { if (scrollTop > __maxScrollTop) { scrollTop = __maxScrollTop; } else { scrollTop = __minScrollTop; } } if (__positions.length > 40) { __positions.splice(0, 20); } __positions.push(scrollTop, e.timeStamp);
__publish(scrollTop);
__startTouchTop = currentTouchTop; __lastTouchMove = e.timeStamp; }; let touchEndHandler = (e) => { if (e.timeStamp - __lastTouchMove < 100) { // 如果抬起时间和最后移动时间小于 100 证明快速滚动过 let positions = __positions; let endPos = positions.length - 1; let startPos = endPos; // 由于保存的时候位置跟时间都保存了, 所以 i -= 2 // positions[i] > (self.__lastTouchMove - 100) 判断是从什么时候开始的快速滑动 for ( let i = endPos; i > 0 && positions[i] > __lastTouchMove - 100; i -= 2 ) { startPos = i; } if (startPos !== endPos) { // 计算这两点之间的相对运动 let timeOffset = positions[endPos] - positions[startPos]; // 快速开始时间 - 结束滚动时间 let movedTop = __scrollTop - positions[startPos - 1]; // 最终距离 - 快速开始距离 // 基于50ms计算每个渲染步骤的移动 __deceleratingMove = (movedTop / timeOffset) * (1000 / 60); // 移动距离是用分钟来计算的
let minVelocityToStartDeceleration = 4; // 开始减速的最小速度 // 只有速度大于最小加速速度时才会出现下面的动画 if (Math.abs(__deceleratingMove) > minVelocityToStartDeceleration) { __startDeceleration(); } } } if (!__isDecelerating) { __scrollTo(__scrollTop); }
__positions.length = 0; };
component.addEventListener("touchstart", touchStartHandler);
component.addEventListener("touchmove", touchMoveHandler);
component.addEventListener("touchend", touchEndHandler);</script> </body></html>


Picker 选择器显示一个或多个选项集合的可滚动列表,相比于原生 picker,实现了 iOS 与 Android 端体验的一致性。


要实现横向 picker,其实跟纵向 picker 差不多,都支持滚动时停留在指定位置,并且支持滚动到边界支持反弹效果。


<!DOCTYPE html><html lang="en">  <head>    <meta charset="UTF-8" />    <meta name="viewport" content="width=device-width, initial-scale=1.0" />    <meta http-equiv="X-UA-Compatible" content="ie=edge" />    <title>picker</title>    <style>      * {        margin: 0;        padding: 0;      }      .scroller-component {        display: block;        position: relative;        height: 34px;        overflow: hidden;        width: 100%;      }
.scroller-content { position: absolute; left: 0; top: 0; z-index: 1; white-space: nowrap; line-height: 0; font-size: 0; }
.scroller-mask { position: absolute; left: 0; top: 0; height: 100%; margin: 0 auto; width: 100%; z-index: 3; transform: translateZ(0px); background-image: linear-gradient( to right, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.6) ), linear-gradient( to left, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.6) ); background-position: left, right; background-size: 102px 100%; background-repeat: no-repeat; }
.scroller-item { text-align: center; font-size: 16px; height: 34px; width: 50px; line-height: 34px; color: #000; display: inline-block; box-sizing: border-box; }
.scroller-indicator { box-sizing: border-box; width: 50px; height: 34px; position: absolute; transform: translate3d(-50%, 0, 0); left: 50%; top: 0; z-index: 3; border: 1px solid red; }
.scroller-item { line-clamp: 1; -webkit-line-clamp: 1; overflow: hidden; text-overflow: ellipsis; }</style> </head>
<body> <div class="scroller-component" data-role="component"> <div class="scroller-mask" data-role="mask"></div> <div class="scroller-indicator" data-role="indicator"></div> <div class="scroller-content" data-role="content"> <div class="scroller-item" data-value="1">1</div> <div class="scroller-item" data-value="2">2</div> <div class="scroller-item" data-value="3">3</div> <div class="scroller-item" data-value="4">4</div> <div class="scroller-item" data-value="5">5</div> <div class="scroller-item" data-value="6">6</div> <div class="scroller-item" data-value="7">7</div> <div class="scroller-item" data-value="8">8</div> <div class="scroller-item" data-value="9">9</div> <div class="scroller-item" data-value="10">10</div> <div class="scroller-item" data-value="11">11</div> <div class="scroller-item" data-value="12">12</div> <div class="scroller-item" data-value="13">13</div> <div class="scroller-item" data-value="14">14</div> <div class="scroller-item" data-value="15">15</div> <div class="scroller-item" data-value="16">16</div> <div class="scroller-item" data-value="17">17</div> <div class="scroller-item" data-value="18">18</div> <div class="scroller-item" data-value="19">19</div> <div class="scroller-item" data-value="20">20</div> </div> </div> <script> let running = {}; // 运行 let counter = 1; // 计时器 let desiredFrames = 60; // 每秒多少帧 let millisecondsPerSecond = 1000; // 每秒的毫秒数
const Animate = { // 停止动画 stop(id) { var cleared = running[id] != null; if (cleared) { running[id] = null; } return cleared; },
// 判断给定的动画是否还在运行 isRunning(id) { return running[id] != null; }, start( stepCallback, verifyCallback, completedCallback, duration, easingMethod, root ) { let start = Date.now(); let percent = 0; // 百分比 let id = counter++; let dropCounter = 0;
let step = function () { let now = Date.now();
if (!running[id] || (verifyCallback && !verifyCallback(id))) { running[id] = null; completedCallback && completedCallback( desiredFrames - dropCounter / ((now - start) / millisecondsPerSecond), id, false ); return; }
if (duration) { percent = (now - start) / duration; if (percent > 1) { percent = 1; } } let value = easingMethod ? easingMethod(percent) : percent; if (percent !== 1 && (!verifyCallback || verifyCallback(id))) { stepCallback(value); window.requestAnimationFrame(step); } };
running[id] = true; window.requestAnimationFrame(step); return id; }, };</script> <script> let component = document.querySelector("[data-role=component]"); // 插件容器 let content = component.querySelector("[data-role=content]"); // 内容容器 let indicator = component.querySelector("[data-role=indicator]"); // 正确位置实线 let __startTouchTop = 0; let __scrollTop = 0;
let __isAnimating = false; // 是否开启动画
let __lastTouchMove = 0; // 最后滚动时间记录 let __positions = []; // 记录位置和时间
let __deceleratingMove = 0; // 减速运动速度 let __isDecelerating = false; // 是否开启减速状态
let __itemHeight = parseFloat(window.getComputedStyle(indicator).width); let __maxScrollTop = __itemHeight / 2; // 滚动最大值 let __minScrollTop = -(content.offsetWidth) + __maxScrollTop; // 滚动最小值
content.style.left = component.clientWidth / 2 - __itemHeight / 2 + 'px';
// 开始快后来慢的渐变曲线 let easeOutCubic = (pos) => { return Math.pow(pos - 1, 3) + 1; }; // 以满足开始和结束的动画 let easeInOutCubic = (pos) => { if ((pos /= 0.5) < 1) { return 0.5 * Math.pow(pos, 3); } return 0.5 * (Math.pow(pos - 2, 3) + 2); }; let __callback = (top) => { const distance = top; content.style.transform = "translate3d(" + distance + "px, 0, 0)"; }; let __publish = (top, animationDuration) => { if (animationDuration) { let oldTop = __scrollTop; let diffTop = top - oldTop; let wasAnimating = __isAnimating;
let step = function (percent) { __scrollTop = oldTop + diffTop * percent; __callback(__scrollTop); }; let verify = function (id) { return __isAnimating === id; }; let completed = function ( renderedFramesPerSecond, animationId, wasFinished) { if (animationId === __isAnimating) { __isAnimating = false; } }; __isAnimating = Animate.start( step, verify, completed, animationDuration, wasAnimating ? easeOutCubic : easeInOutCubic ); } else { __scrollTop = top; __callback(top); } }; // 滚动到正确位置的方法 let __scrollTo = (top) => { top = Math.round((top / __itemHeight).toFixed(5)) * __itemHeight; let newTop = Math.max(Math.min(__maxScrollTop, top), __minScrollTop); if (top !== newTop) { if (newTop >= __maxScrollTop) { top = newTop - __itemHeight / 2; } else { top = newTop + __itemHeight / 2; } } __publish(top, 250); }; // 开始减速动画 let __startDeceleration = () => { let step = () => { let scrollTop = __scrollTop + __deceleratingMove; let scrollTopFixed = Math.max( Math.min(__maxScrollTop, scrollTop), __minScrollTop ); // 不小于最小值,不大于最大值 if (scrollTopFixed !== scrollTop) { scrollTop = scrollTopFixed; __deceleratingMove = 0; } if (Math.abs(__deceleratingMove) <= 1) { if (Math.abs(scrollTop % __itemHeight) < 1) { __deceleratingMove = 0; } } else { __deceleratingMove *= 0.95; } __publish(scrollTop); }; let minVelocityToKeepDecelerating = 0.5; let verify = () => { // 保持减速运行需要多少速度 let shouldContinue = Math.abs(__deceleratingMove) >= minVelocityToKeepDecelerating; return shouldContinue; }; let completed = function ( renderedFramesPerSecond, animationId, wasFinished) { __isDecelerating = false; if (__scrollTop <= __minScrollTop || __scrollTop >= __maxScrollTop) { __scrollTo(__scrollTop); return; } }; __isDecelerating = Animate.start(step, verify, completed); }; let touchStartHandler = (e) => { e.preventDefault(); const target = e.touches ? e.touches[0] : e; __startTouchTop = target.pageX; }; let touchMoveHandler = (e) => { const target = e.touches ? e.touches[0] : e; let currentTouchTop = target.pageX; let moveY = currentTouchTop - __startTouchTop; let scrollTop = __scrollTop; scrollTop = scrollTop + moveY; if (scrollTop > __maxScrollTop || scrollTop < __minScrollTop) { if (scrollTop > __maxScrollTop) { scrollTop = __maxScrollTop; } else { scrollTop = __minScrollTop; } } if (__positions.length > 40) { __positions.splice(0, 20); } __positions.push(scrollTop, e.timeStamp);
__publish(scrollTop);
__startTouchTop = currentTouchTop; __lastTouchMove = e.timeStamp; }; let touchEndHandler = (e) => { if (e.timeStamp - __lastTouchMove < 100) { // 如果抬起时间和最后移动时间小于 100 证明快速滚动过 let positions = __positions; let endPos = positions.length - 1; let startPos = endPos; // 由于保存的时候位置跟时间都保存了, 所以 i -= 2 // positions[i] > (self.__lastTouchMove - 100) 判断是从什么时候开始的快速滑动 for ( let i = endPos; i > 0 && positions[i] > __lastTouchMove - 100; i -= 2 ) { startPos = i; } if (startPos !== endPos) { // 计算这两点之间的相对运动 let timeOffset = positions[endPos] - positions[startPos]; // 快速开始时间 - 结束滚动时间 let movedTop = __scrollTop - positions[startPos - 1]; // 最终距离 - 快速开始距离 // 基于50ms计算每个渲染步骤的移动 __deceleratingMove = (movedTop / timeOffset) * (1000 / 60); // 移动距离是用分钟来计算的
let minVelocityToStartDeceleration = 4; // 开始减速的最小速度 // 只有速度大于最小加速速度时才会出现下面的动画 if (Math.abs(__deceleratingMove) > minVelocityToStartDeceleration) { __startDeceleration(); } } } if (!__isDecelerating) { __scrollTo(__scrollTop); }
__positions.length = 0; };
component.addEventListener("touchstart", touchStartHandler);
component.addEventListener("touchmove", touchMoveHandler);
component.addEventListener("touchend", touchEndHandler);</script> </body></html>


浏览 3
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报