原生 JS 实现移动端 Picker 组件

Picker 是指提供多个选项集合供用户选择其中一项的控件。Picker 展示区域有限,部分选项会被隐藏,最好是当用户对所有选项都比较熟悉、有预期的时候,才使用 Picker。
<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 差不多,都支持滚动时停留在指定位置,并且支持滚动到边界支持反弹效果。

<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>
评论
