横向picker
主要运用在移动端项目中,通常的组件是通过纵向的滑动来选择,使用transform的问题是页面虽然旋转过来了,但是让他左右移动时的动作要与旋转前一样,不符合操作逻辑,因此造了这个轮子。有需要的可以点赞收藏。
效果如下
vue代码
需要传入pickerData数组数据,返回事件result,代码中已添加滑动变化时震动
震动代码为:
//跳过边界震动
if (!(y > option.maxX || y < -option.minX) && item !== option.item) {
navigator.vibrate(20);
option.item = item;
}
横向完整代码
<template>
<div>
<div class="picker-group">
<div
class="picker-row"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
>
<ul
class="picker-content"
:style="
'transform: translate3d(' +
option.translateX +
'px, 0px, 0px);display: flex;transition:' +
option.pointerdown +
' ease 0s'
"
>
<li
v-for="(i, o) in state.pickerData"
:key="o"
:class="option.activeItem == o ? 'active' : ''"
>
{{ i }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import { reactive } from "@vue/reactivity";
import { onMounted } from "@vue/runtime-core";
export default {
props: {
pickerData: {
typeof: Array,
default: () => {
return [];
},
},
},
setup(props, { emit }) {
const state = reactive({
pickerData: props.pickerData,
});
const option = reactive({
isPointerdown: false,
pointerdown: "transform 300ms",
ul: null,
pickerRow: null,
itemWidth: 120, // 列表项宽度
pseudoWidth: 0, //伪元素宽度
maxX: 0, //初始位置也是最大的位置
minX: 0, //最小的位置
lastX: 0,
diffX: 0,
translateX: 0, // 当前位置
friction: 0.95, // 摩擦系数
distanceX: 0, // 滑动距离
activeItem: 0,
result: state.pickerData[0],
item: 0, //监听变化震动
});
/**
* @description: 初始化
* @return {*}
*/
const render = () => {
option.ul = document.querySelector(".picker-content");
option.pickerRow = document.querySelector(".picker-row");
option.itemWidth = option.ul.querySelector("li").offsetWidth; // 列表项宽度
option.pseudoWidth = window.getComputedStyle(
option.pickerRow,
"after"
).width; //伪元素宽度
option.maxX = option.pseudoWidth.replace(/[a-zA-Z]+/g, ""); //初始位置也是最大的位置
option.minX =
option.itemWidth * (state.pickerData.length - 1) - option.maxX; //最小的位置
option.translateX = option.maxX;
emit("result", option.result);
};
/**
* @description: 点击
* @param {*} e
* @return {*}
*/
const touchStart = (e) => {
option.isPointerdown = true;
option.lastX = e.touches[0].clientX;
option.diffX = 0;
option.distanceX = 0;
getTransform();
};
/**
* @description: 移动
* @param {*} e
* @return {*}
*/
const touchMove = (e) => {
if (option.isPointerdown) {
option.diffX = e.touches[0].clientX - option.lastX;
option.translateX += option.diffX;
option.lastX = e.touches[0].clientX;
//震动操作
let y = option.translateX + option.distanceX;
let item = Math.round(
(option.translateX - option.maxX) / option.itemWidth
);
//跳过边界震动
if (!(y > option.maxX || y < -option.minX) && item !== option.item) {
option.activeItem = Math.abs(item);
navigator.vibrate(20);
option.item = item;
}
}
};
/**
* @description: 结束
* @param {*} e
* @return {*}
*/
const touchEnd = () => {
if (option.isPointerdown) {
option.isPointerdown = false;
getTranslateX();
// 滑动距离与时长成正比且最短时长为300ms
const duration = Math.max(Math.abs(option.distanceX) * 1.5, 300);
option.ul.style.transition = "transform " + duration + "ms ease";
option.pointerdown = "transform 300ms";
}
};
/**
* @description: 设置位置及返回数据
* @return {*}
*/
const getTranslateX = () => {
let speed = option.diffX;
while (Math.abs(speed) > 1) {
speed *= option.friction;
option.distanceX += speed;
}
// 边界判断
let y = option.translateX + option.distanceX;
if (y > option.maxX) {
option.translateX = option.maxX;
option.distanceX = option.maxX - option.translateX;
} else if (y < -option.minX) {
option.translateX = -option.minX;
option.distanceX = option.minX - option.translateX;
} else {
option.translateX = y;
}
// 计算停止位置使其为itemWidth的整数倍
let i = Math.round((option.translateX - option.maxX) / option.itemWidth);
option.translateX = Number(option.maxX) + Number(i * option.itemWidth);
option.activeItem = Math.abs(i);
option.result = state.pickerData[Math.abs(i)];
emit("result", option.result);
};
/**
* @description: 设点击时初始位置
* @return {*}
*/
const getTransform = () => {
const transform = window
.getComputedStyle(option.ul)
.getPropertyValue("transform");
option.translateX = parseFloat(transform.split(",")[4]);
option.pointerdown = "none 0s";
};
onMounted(() => {
render();
});
return { state, option, touchStart, touchMove, touchEnd };
},
};
</script>
<style lang="less" scoped>
.picker-group {
display: flex;
}
.picker-row {
position: relative;
flex: 1;
margin: auto 0;
overflow: hidden;
touch-action: none;
display: flex;
}
.picker-row::before {
content: "";
position: absolute;
display: inline;
top: 0;
left: 0;
bottom: 0;
z-index: 1;
width: calc(~"50% - 60px");
border-right: 1px solid #ebebeb;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.6),
rgba(255, 255, 255, 0.9)
);
}
.picker-row::after {
content: "";
position: absolute;
display: inline;
bottom: 0;
right: 0;
top: 0;
z-index: 1;
width: calc(~"50% - 60px");
border-left: 1px solid #ebebeb;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.6)
);
}
li {
list-style: none;
font-size: 14px;
width: 120px;
line-height: 20px;
text-align: center;
}
.active {
font-size: 16px;
font-weight: 700;
}
.btn-sure {
display: block;
margin: 15px auto 0;
}
</style>
3D滚动代码
<template>
<div>
<div class="picker-group">
<div class="picker-row">
<ul
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
class="picker-content"
:style="
'transform: rotateY(' +
option.rotateY +
'deg);transition:' +
option.pointerdown +
' ease 0s'
"
>
<li
v-for="(i, o) in state.pickerData"
:key="o"
:style="'transform: rotateY(' + o * 45 + 'deg) translateZ(160px); '"
:class="option.activeItem == o ? 'active' : ''"
>
{{ i }}
</li>
<li
:style="
'transform: rotateY(' +
Number(option.minX + 45) +
'deg) translateZ(160px)'
"
>
</li>
<li
v-if="state.pickerData.length == 6"
:style="
'transform: rotateY(' +
Number(option.minX + 90) +
'deg) translateZ(160px)'
"
>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import { reactive } from "@vue/reactivity";
import { onMounted } from "@vue/runtime-core";
export default {
props: {
pickerData: {
typeof: Array,
default: () => {
return [];
},
},
},
setup(props, { emit }) {
const state = reactive({
pickerData: props.pickerData,
});
const option = reactive({
isPointerdown: false,
pointerdown: "transform 300ms",
degPx: 0, // 多少px为1度
maxX: 0, //初始位置也是最大的位置
minX: 0, //最小的位置
lastX: 0,
diffX: 0, // 移动过程中的微小距离变化
rotateY: 0, //旋转角度
friction: 0.95, // 摩擦系数
distanceX: 0, // 滑动距离
activeItem: 0,
result: state.pickerData[0],
item: 0, //监听变化震动
dataLength: 0,
});
/**
* @description: 初始化
* @return {*}
*/
const render = () => {
option.degPx = 3; // 多少px为1度
option.dataLength = state.pickerData.length;
option.maxX = 0; //初始位置也是最大的位置
option.minX = (option.dataLength - 1) * 45; //最小的位置
emit("result", option.result);
};
/**
* @description: 点击
* @param {*} e
* @return {*}
*/
const touchStart = (e) => {
option.isPointerdown = true;
option.lastX = e.touches[0].clientX;
option.diffX = 0;
option.distanceX = 0;
option.pointerdown = "none 0s";
};
/**
* @description: 移动
* @param {*} e
* @return {*}
*/
const touchMove = (e) => {
if (option.isPointerdown) {
option.diffX = e.touches[0].clientX - option.lastX;
option.lastX = e.touches[0].clientX;
option.rotateY += option.diffX / option.degPx;
if (option.rotateY > option.maxX) {
option.rotateY = 0;
} else if (option.rotateY < -option.minX) {
option.rotateY = -option.minX;
}
//震动
let y = option.rotateY + option.distanceX / option.degPx;
let item = Math.round((option.rotateY - 0) / 45);
//跳过边界震动
if (!(y > option.maxX || y < -option.minX) && item !== option.item) {
if (navigator.vibrate) navigator.vibrate(20);
option.item = item;
option.activeItem = Math.abs(item);
}
}
};
/**
* @description: 结束
* @param {*} e
* @return {*}
*/
const touchEnd = () => {
if (option.isPointerdown) {
option.isPointerdown = false;
getTranslateX();
// 滑动距离与时长成正比且最短时长为300ms
const duration = Math.max(Math.abs(option.distanceX), 300);
option.pointerdown = "transform " + duration + "ms";
}
};
/**
* @description: 设置位置及返回数据
* @return {*}
*/
const getTranslateX = () => {
let speed = option.diffX;
while (Math.abs(speed) > 1) {
speed *= option.friction;
option.distanceX += speed;
}
// 边界判断
let y = option.rotateY + option.distanceX / option.degPx; // 原始位置与变化位置之和
if (y > 0) {
option.rotateY = 0;
} else if (y < -option.minX - 45) {
option.rotateY = -option.minX;
} else {
option.rotateY = y;
}
// 计算停止位置使其为itemWidth的整数倍
let i = Math.round((option.rotateY - 0) / 45);
if (Math.abs(i) >= option.dataLength) {
i = -option.dataLength + 1;
}
option.rotateY = i * 45;
option.activeItem = Math.abs(i);
option.result = state.pickerData[Math.abs(i)];
emit("result", option.result);
};
onMounted(() => {
render();
});
return { state, option, touchStart, touchMove, touchEnd };
},
};
</script>
<style lang="less" scoped>
.picker-group {
display: flex;
}
.picker-row {
margin: 0 auto;
width: 120px;
height: 30px;
position: relative;
.picker-content {
height: 100%;
width: 100%;
position: absolute;
transform-style: preserve-3d;
}
}
li {
display: block;
position: absolute;
width: 135px;
background: #fff;
font-size: 1em;
text-align: center;
color: #000;
}
.active {
font-size: 16px;
font-weight: 700;
}
.btn-sure {
display: block;
margin: 15px auto 0;
}
</style>
html代码
可以直接运行的html代码。
<!DOCTYPE html>
<!-- saved from url=(0052)http://jsdemo.codeman.top/html/pickerTransition.html -->
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
/>
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>pickerTransition</title>
<link
rel="icon"
type="image/x-icon"
href="http://jsdemo.codeman.top/images/favicon.ico"
/>
<style>
* {
margin: 0;
padding: 0;
}
.btn {
height: 32px;
padding: 0 15px;
text-align: center;
font-size: 14px;
line-height: 32px;
color: #fff;
border: none;
background: #1890ff;
border-radius: 2px;
cursor: pointer;
}
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.6);
animation: fadeIn 0.3s forwards;
}
.slide-box {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 15px;
border-radius: 10px 10px 0 0;
background: #fff;
user-select: none;
}
h4 {
height: 24px;
margin-bottom: 16px;
font-size: 16px;
line-height: 24px;
text-align: center;
}
.picker-group {
display: flex;
}
.picker-row {
position: relative;
flex: 1;
height: 200px;
margin: auto 0;
overflow: hidden;
touch-action: none;
display: flex;
}
.picker-row::before {
content: "";
position: absolute;
display: inline;
top: 0;
left: 0;
bottom: 0;
z-index: 1;
height: 79px;
width: calc(50% - 28px);
border-right: 1px solid #ebebeb;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.6),
rgba(255, 255, 255, 0.9)
);
}
.picker-row::after {
content: "";
position: absolute;
display: inline;
bottom: 0;
right: 0;
top: 0;
z-index: 1;
width: calc(50% - 28px);
height: 79px;
border-left: 1px solid #ebebeb;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.6)
);
}
li {
list-style: none;
font-size: 14px;
width: 56px;
line-height: 40px;
text-align: center;
}
.btn-sure {
display: block;
margin: 15px auto 0;
}
</style>
</head>
<body>
<div class="mask">
<div class="slide-box">
<h4>时间选择器</h4>
<div class="picker-group">
<div class="picker-row">
<ul
class="picker-content"
style="
transform: translate3d(calc(50% - 28px), 0px, 0px);
transition: transform 300ms ease 0s;
display: flex;
"
>
<li>s</li>
</ul>
</div>
</div>
<button class="btn btn-sure" type="button">确定</button>
</div>
</div>
<script>
const btnSure = document.querySelector(".btn-sure");
const slide = document.querySelector(".slide-box");
btnSure.addEventListener("click", function (e) {
alert(hourPicker.result);
e.preventDefault();
});
/**
* 原生javascript实现picker
*/
class Picker {
constructor(options) {
this.options = Object.assign({}, options);
this.isPointerdown = false;
const ul = document.querySelector(".picker-content");
const pickerRow = document.querySelector(".picker-row");
this.itemWidth = ul.querySelector("li").offsetWidth; // 列表项宽度
const pseudoWidth = window.getComputedStyle(pickerRow, "after").width; //伪元素宽度
this.maxX = pseudoWidth.replace(/[a-zA-Z]+/g, ""); //初始位置也是最大的位置
this.minX =
this.itemWidth * (this.options.list.length - 1) - this.maxX; //最小的位置
this.lastX = 0;
this.diffX = 0;
this.translateX = 0; // 当前位置
this.friction = 0.95; // 摩擦系数
this.distanceX = 0; // 滑动距离
this.result = this.options.list[0];
this.render();
this.bindEventListener();
}
render() {
let html = "";
for (const item of this.options.list) {
html += "<li>" + item + "</li>";
}
this.options.pickerContent.innerHTML = html;
this.options.pickerContent.style.transform =
"translate3d(" + this.maxX + "px, 0px, 0px)";
}
handlePointerdown(e) {
// 如果是鼠标点击,只响应左键
if (e.pointerType === "mouse" && e.button !== 0) {
return;
}
this.options.pickerColumn.setPointerCapture(e.pointerId);
this.isPointerdown = true;
this.lastX = e.clientX;
this.diffX = 0;
this.distanceX = 0;
this.getTransform();
this.options.pickerContent.style.transform =
"translate3d(" + this.translateX + "px, 0px, 0px)";
this.options.pickerContent.style.transition = "none";
}
handlePointermove(e) {
if (this.isPointerdown) {
this.diffX = e.clientX - this.lastX;
this.translateX += this.diffX;
this.lastX = e.clientX;
this.options.pickerContent.style.transform =
"translate3d(" + this.translateX + "px, 0px, 0px)";
}
}
handlePointerup(e) {
if (this.isPointerdown) {
this.isPointerdown = false;
this.getTranslateX();
// 滑动距离与时长成正比且最短时长为300ms
const duration = Math.max(Math.abs(this.distanceX) * 1.5, 300);
this.options.pickerContent.style.transition =
"transform " + duration + "ms ease";
this.options.pickerContent.style.transform =
"translate3d(" + this.translateX + "px, 0px, 0px)";
}
}
handlePointercancel(e) {
if (this.isPointerdown) {
this.isPointerdown = false;
}
}
bindEventListener() {
this.handlePointerdown = this.handlePointerdown.bind(this);
this.handlePointermove = this.handlePointermove.bind(this);
this.handlePointerup = this.handlePointerup.bind(this);
this.handlePointercancel = this.handlePointercancel.bind(this);
this.options.pickerColumn.addEventListener(
"pointerdown",
this.handlePointerdown
);
this.options.pickerColumn.addEventListener(
"pointermove",
this.handlePointermove
);
this.options.pickerColumn.addEventListener(
"pointerup",
this.handlePointerup
);
this.options.pickerColumn.addEventListener(
"pointercancel",
this.handlePointercancel
);
}
getTransform() {
const transform = window
.getComputedStyle(this.options.pickerContent)
.getPropertyValue("transform");
this.translateX = parseFloat(transform.split(",")[4]);
}
getTranslateX() {
let speed = this.diffX;
while (Math.abs(speed) > 1) {
speed *= this.friction;
this.distanceX += speed;
}
// 边界判断
let y = this.translateX + this.distanceX;
if (y > this.maxX) {
this.translateX = this.maxX;
this.distanceX = this.maxX - this.translateX;
} else if (y < -this.minX) {
this.translateX = -this.minX;
this.distanceX = this.minX - this.translateX;
} else {
this.translateX = y;
}
// 计算停止位置使其为itemWidth的整数倍
let i = Math.round((this.translateX - this.maxX) / this.itemWidth);
this.translateX = Number(this.maxX) + Number(i * this.itemWidth);
this.result = this.options.list[Math.abs(i)];
}
}
// 调用方式
function createList(start, end) {
const list = [];
for (i = start; i < end; i++) {
list[i] = i < 10 ? "0" + i : "" + i;
}
return list;
}
const hours = createList(0, 24);
const pickerColumns = document.querySelectorAll(".picker-row");
const pickerContents = document.querySelectorAll(".picker-content");
const hourPicker = new Picker({
pickerColumn: pickerColumns[0],
pickerContent: pickerContents[0],
list: hours,
});
</script>
</body>
</html>