原生 JS 实现移动端 Picker 组件
前端精髓
共 27714字,需浏览 56分钟
·
2024-04-11 17:46
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>
评论