一. Picker组件:
<template>
<div class="picker-container">
<div>
<transition name="myOpacity">
<section class="pop-cover" @touchstart="close" v-show="value"></section>
</transition>
<transition name="myPopup">
<section v-if="value">
<div class="btn-box"><button @touchstart="close">取消</button><button @touchstart="sure">确认</button></div>
<section class="aaa">
<div class="gg" :style="ggStyle">
<div class="col-wrapper" :style="getWrapperHeight">
<ul class="wheel-list" :style="getListTop" ref="wheel">
<li class="wheel-item" v-for="(item,index) in values" :style="initWheelItemDeg(index)">{{item}}</li>
</ul>
<div class="cover" :style="getCoverStyle"></div>
<div class="divider" :style="getDividerStyle"></div>
</div>
</div>
</section>
</section>
</transition>
</div>
</div>
</template>
<script>
import Animate from '../../../utils/animate';
const a = -0.003; // 加速度
let radius = 2000; // 半径--console.log(Math.PI*2*radius/lineHeight)=>估算最多可能多少条,有较大误差,半径2000应该够用了,不够就4000
const lineHeight = 36; // 文字行高
let isInertial = false; // 是否正在惯性滑动
// 反正切=>得到弧度再转换为度数,这个度数是单行文字所占有的。
let singleDeg = 2 * ((Math.atan((lineHeight/2)/radius) * 180)/ Math.PI);
const remUnit = 37.5; // 默认屏幕宽度是375px时html的font-size大小
export default {
props:{
cuIdx: {type: Number,default: 0},
value: false,
values: {type: Array,default:() => []}
},
data() {
return {
finger: {
startY: 0,
startTime: 0, // 开始滑动时间(单位:毫秒)
currentMove: 0,
prevMove: 0,
},
currentIndex: 0,
};
},
computed: {
// 限制滚动区域的高度,内容正常显示--以下皆未使用this,所以可以用箭头函数简化写法
ggStyle: () => ({ maxHeight: `${radius/remUnit}rem`, transform: `translateY(-${(radius - 300/2)/remUnit}rem` }),
// 3d滚轮的内容区域样式--ref=wheel的元素样式
getListTop: () => ({ top: `${(radius - (lineHeight / 2))/remUnit}rem`, height: `${lineHeight/remUnit}rem` }),
// 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
getWrapperHeight: () => ({ height: `${2 * radius/remUnit}rem` }),
// 参照一般居中的做法,50%*父页面的高度(整个圆的最大高度是直径)-居中内容块(文本的行高)的一半高度
getCoverStyle: () => ({ backgroundSize: `100% ${(radius - (lineHeight / 2))/remUnit}rem` }),
// 应该也是参照居中的做法
getDividerStyle:() => ({ top: `${(radius - (lineHeight / 2))/remUnit}rem`,height: `${(lineHeight - 2)/remUnit}rem` }),
animate: () => (new Animate()),
},
mounted() {
this.$el.addEventListener('touchstart', this.listenerTouchStart, false);
this.$el.addEventListener('touchmove', this.listenerTouchMove, false);
this.$el.addEventListener('touchend', this.listenerTouchEnd, false);
},
beforeDestory() {
this.$el.removeEventListener('touchstart', this.listenerTouchStart, false);
this.$el.removeEventListener('touchmove', this.listenerTouchMove, false);
this.$el.removeEventListener('touchend', this.listenerTouchEnd, false);
},
methods: {
initWheelItemDeg(index) {// 初始化时转到父页面传递的下标所对应的选中的值
return { transform: `rotate3d(1, 0, 0, ${(-1 * index
+Number(this.cuIdx)) * singleDeg}deg) translate3d(0, 0, ${radius/remUnit}rem)`,
height: `${lineHeight/remUnit}rem`, lineHeight: `${lineHeight/remUnit}rem` };
},
listenerTouchStart(ev) {
ev.stopPropagation(); ev.preventDefault();
isInertial = false; // 初始状态没有惯性滚动
this.finger.startY = ev.targetTouches[0].pageY; // 获取手指开始点击的位置
this.finger.prevMove = this.finger.currentMove;
this.finger.startTime = Date.now();
},
listenerTouchMove(ev) {
ev.stopPropagation(); ev.preventDefault();
this.finger.currentMove = (this.finger.startY - ev.targetTouches[0].pageY) + this.finger.prevMove;
this.$refs.wheel.style.transform = `rotate3d(1, 0, 0, ${(this.finger.currentMove / lineHeight) * singleDeg}deg)`;
},
listenerTouchEnd(ev) {
ev.stopPropagation(); ev.preventDefault();
const _endY = ev.changedTouches[0].pageY;
const _entTime = Date.now();
const v = (this.finger.startY - _endY)/ (_entTime - this.finger.startTime);// 滚动完毕求移动速度 v = (s初始-s结束) / t
const absV = Math.abs(v);
isInertial = true;// 最好惯性滚动,才不会死板
this.inertia(absV, Math.round(absV / v), 0);// Math.round(absV / v)=>+/-1
},
/**用户结束滑动,应该慢慢放慢,最终停止。从而需要 a(加速度)
* @param start 开始速度 @param position 速度方向,值: 正负1--向上是+1,向下是-1 @param target 结束速度
*/
inertia(start, position, target) {
if (start <= target || !isInertial) {
this.animate.stop();
this.finger.prevMove = this.finger.currentMove;
this.getSelectValue(this.finger.currentMove);// 得到选中的当前下标
return;
}
// 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
const minIdx = 0-this.cuIdx;
const maxIdx = this.values.length-1-this.cuIdx;
const freshTime = 1000 / 60;// 动画帧刷新的频率大概是1000 / 60
// 这段时间走的位移 S = vt + 1/2at^2 + s1;
const move = (position * start * freshTime) + (0.5 * a * Math.pow(freshTime,2)) + this.finger.currentMove;
const newStart = (position * start) + (a * freshTime);// 根据求末速度公式: v末 = (+/-)v初 + at
let moveDeg = Math.round(move / lineHeight) * singleDeg;// 正常的滚动角度
let actualMove = move; // 最后的滚动距离
this.$refs.wheel.style.transition = '';
// 已经到达目标
if (Math.abs(newStart) <= Math.abs(target)) {
// 让滚动在文字区域内,超出区域的滚回到边缘的第一个文本处
if(Math.round(move / lineHeight) < minIdx) {
moveDeg = minIdx*singleDeg;
actualMove =minIdx*lineHeight;
}else if(Math.round(move / lineHeight) > maxIdx) {
moveDeg = maxIdx*singleDeg;
actualMove = maxIdx*lineHeight;
}
this.$refs.wheel.style.transition = 'transform 700ms cubic-bezier(0.19, 1, 0.22, 1)';
}
// this.finger.currentMove赋值是为了点击确认的时候可以使用=>获取选中的值
this.finger.currentMove = actualMove;
this.$refs.wheel.style.transform = `rotate3d(1, 0, 0, ${moveDeg}deg)`;
this.animate.start(this.inertia.bind(this, newStart, position, target));
},
// 滚动时及时获取最新的当前下标--因为在初始化的时候减去了,所以要加上cuIdx,否则下标会不准确
getSelectValue(move) { this.currentIndex = Math.round(move / lineHeight) + Number(this.cuIdx); },
sure() {// 点击确认按钮
this.getSelectValue(this.finger.currentMove);
this.$nextTick(()=>{
this.$emit('select', this.values[this.currentIndex]);
this.close();
});
},
close() { this.$nextTick(()=>{ this.$emit('input',false); }); },// 点击取消按钮
},
};
</script>
<style lang="scss" scoped>
@function px2rem($px) {
$item: 37.5px;
@return $px/$item+rem;
}
.myOpacity-enter,.myOpacity-leave-to {opacity: 0;}
.myOpacity-enter-active,.myOpacity-leave-active {transition: all .5s ease;}
.myPopup-enter,.myPopup-leave-to {transform: translateY(100px);}
.myPopup-enter-active,.myPopup-leave-active {transition: all .5s ease;}
.picker-container {position: fixed;bottom: 0;left: 0;right: 0;}
.pop-cover {position: fixed;top: 0;left: 0;right: 0;height: 100vh;background: rgba(0,0,0,0.5);z-index: -1;}
.btn-box {height: px2rem(40px);background: rgb(112,167,99);display: flex;justify-content: space-between;}
.aaa {height: px2rem(300px);overflow: hidden;}//overflow: hidden=>截掉多余的部分,显示弹窗内容部分
ul, li{list-style: none;padding: 0;margin: 0;}
// 为了方便掌握重点样式,简单的就直接一行展示,其他的换行展示,方便理解
.col-wrapper{
position: relative;
border: 1px solid #CCC;text-align: center;background: #fff;
.wheel-list{
position: absolute;
width: 100%;
transform-style: preserve-3d;
transform: rotate3d(1, 0, 0, 0deg);
.wheel-item{
backface-visibility: hidden;
position: absolute;
left: 0;
top: 0;
width: 100%;
}
}
.cover{
position: absolute;left: 0;top: 0;right: 0;bottom: 0;
background: linear-gradient(180deg,hsla(0,0%,100%,.95),hsla(0,0%,100%,.6)),linear-gradient(0deg,hsla(0,0%,100%,.95),hsla(0,0%,100%,.6));
background-position: top,bottom;
background-repeat: no-repeat;
}
.divider{
position: absolute;width: 100%;left: 0;
border-top: 1px solid #cccccc;border-bottom: 1px solid #cccccc;
}
}
</style>
注意: 反正切计算出来的角度要乘以2
所以通过反正切方法得到弧度再转换为度数, 这个度数是单行文字所占有的, 公式如下。
let singleDeg = 2 * ((Math.atan((lineHeight/2)/radius) * 180)/ Math.PI);
1-1. singleDeg实现原理: 反正切函数arctan
arctan是反正切函数,它的输入为一个正切值,输出的值在(-π/2, π/2)之间, 因此还需要将输出的值 * 180 / π, 从而得到对应的角度大小。
为什么是*180?
因为π对应的角度是180度
推导流程:
如图, 半径是OA之间的线段, line-height是TT'之间的线段, ª夹角 = arctan(AT之间的距离 / 半径) * 180 / π
而AT之间的距离是line-height数值的1/2
而我们实际需要的是TOT'之间的角度, 所以得到ª角度角度后要 * 2
把picker先看成一个球形
translateY实现垂直居中:
比如:
当从上往下平移时:
.father {
border: 1px solid #333;
height: 300px;
}
.box {
width: 100px;
height: 100px;
border: 1px solid #333;
background-color: yellow;
transform: translateY(calc(150px - 50px));
/* 1/2father的height - 1/2自身高度*/
}
或者
.father {
border: 1px solid #333;
height: 300px;
position: relative;
}
.box {
position: absolute;
width: 100px;
height: 100px;
border: 1px solid #333;
background-color: yellow;
top: calc(150px - 50px);
/* 1/2father的height - 1/2自身高度*/
}
当从下往上平移时
.father {
position: relative;
border: 1px solid #333;
height: 300px;
}
.box {
position: absolute;
bottom: 0;
width: 120px;
height: 100px;
border: 1px solid #333;
background-color: yellow;
transform: translateY(calc(0px - (150px - 50px))); /* - (1/2father的height - 1/2自身高度)*/
}
而scss的.picker-container写了
position: fixed;
bottom: 0;
那么子元素垂直居中就要transform: translateY(1/2father的height - 1/2自身高度);
反过来写: transform: translateY(-(1/2自身高度 - 1/2father的height));
代码eg:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 400px;
height: 300px;
background: blue;
border: 1px solid #333;
}
.col-wrapper {
position: relative;
width: 100px;
height: 100px;
border: 1px solid #333;
background-color: yellow;
transform: translateY(calc(150px - 50px));
/* (1/2father的height - 1/2自身高度)*/
}
</style>
</head>
<body>
<div class="container">
<div class="col-wrapper"></div>
</div>
</body>
</html>
效果图如下(黄色块垂直居中):
所以.col-wrapper的style样式:
// class为col-wrapper的style样式: 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
const getWrapperStyle = computed(() => ({
height: pxToRem(2 * radius),
// 居中: 1/2直径 - 1/2父页面高度
transform: `translateY(-${pxToRem(radius - SCROLL_CONTENT_HEIGHT / 2)})`
}));
.col-wrapper的子元素们:
// 当父元素(class为col-wrapper), 定位是relative, 高度是直径: 2 * radius, 子页面想要居中, top: (1/2直径)-(1/2*一行文字高度)
const circleTop = pxToRem(radius - (LINE_HEIGHT / 2)); // 很重要!!!
// col-wrapper的子元素 => 3d滚轮的内容区域样式--ref=wheel的元素样式
const getListTop = computed(() => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT)
}))
// col-wrapper的子元素 => 参照一般居中的做法,[50%*父页面的高度(整个圆的最大高度是直径)]-居中内容块(文本的行高)的一半高度
const getCoverStyle = computed(() => {
return {
backgroundSize: `100% ${circleTop}`,
}
})
// col-wrapper的子元素 => 应该也是参照居中的做法(注意减去两条边框线)
const getDividerStyle = computed(() => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT),
}))
做到这些, 效果图如下:
文字糊作一团了, 因为z轴方向是正对着我们的, 应该是z轴不平移的话就离我们太远了
给.wheel-item加个边框线, 改变translateZ的值, 看看效果:
沿z轴平移0时:
沿z轴平移半径的一半时:
当然沿z轴平移半径的距离时, 页面就正常了, 一般越往z轴正方向移动, 我们眼中的物体越大, 从下方的这个网上找来的图也能看出这个结论:
沿z轴平移:translateZ
Z轴方向平移半径的距离: 让picker把父页面传过来的picker数组数据依次展示
如果平移(负半径)的距离, 数据就是相反的, 如下图所示:
按要求设置translateZ(${radiusRem})后, 发现文字还是糊作一团(因为wheel-item的定位是absolute)
因此还需要沿x轴旋转, 就像扇子一样, 打开自然就形成弧形了, 如果扇子的纸张足够厚, 打开然后将弧形面正对我们的眼睛方向, 大概就是明显的picker弧面效果了.
沿x轴旋转: rotateX
x轴是我们常说的从左往右的方向;
每行文字都沿着x轴旋转, 旋转的角度按下标和默认一行文字旋转角度singleDeg一起判断
// 初始化时需要让picker滑到父页面传的当前选中的下标cuIdx处, 其他的文字按顺序排列
// 因为是从下往上滑, 所以得到 -(index - cuIdx) , 再乘以单行文字的角度, 就是每行文字旋转的角度
const indexNum = -1 * index + Number(cuIdx);
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const wheelItemDeg = indexNum * singleDeg
最后picker才能正常显示:
/**
* 1、translate3d
在浏览器中,y轴正方向垂直向下,x轴正方向水平向右,z轴正方向指向外面。
z轴越大离我们越近,即看到的物体越大。z轴说物体到屏幕的距离。
*
*/
function getInitWheelItemTransform(indexNum) {// 初始化时转到父页面传递的下标所对应的选中的值
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const rotate3dValue = getMoveWheelItemTransform(indexNum * LINE_HEIGHT);
return `${rotate3dValue} translateZ(calc(${radiusRem} / 1))`
}
function getMoveWheelItemTransform(move) {// 初始化时转到父页面传递的下标所对应的选中的值
const indexNum = Math.round(move / LINE_HEIGHT);
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const wheelItemDeg = indexNum * singleDeg
return `rotateX(${wheelItemDeg}deg)`
}
注意事项:
wheel-item的position必须是absolute, 且相对于wheel-list定位的, 否则x轴旋转没有效果
所以:
.wheel-list {
position: absolute; /*因为父级元素.col-wrapper的定位是relative, 这里要垂直居中选择absolute*/
top: 1/2父元素高度 - 1/2自身高度;
height: 文字行高;
line-height: 文字行高;
transform-style: preserve-3d;
}
.wheel-item {
position: absolute; /*相对于最近的父级元素,且有position属性的父级元素定位*/
top: 0;
其他略...
}
页面布局的部分代码:
html文件(检查transform效果)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>3d滚轮picker-html示例-px单位版本</title>
<style>
* {
margin: 0;
padding: 0;
}
li {
/*因为要减去2个border线的高度*/
line-height: calc(36px - 2px);
list-style: none;
border: 1px solid #eee;
box-sizing: border-box;
width: 100vw;
text-align: center;
box-sizing: border-box;
}
.picker-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
.col-wrapper-father {
height: 300px;
overflow: hidden;
}
.col-wrapper {
position: relative;
text-align: center;
background: #fff;
}
.wrapper {
position: fixed;
bottom: 0;
height: 300px;
}
.wheel-list,
#wheel {
position: absolute;
width: 100%;
transform-style: preserve-3d;
transform: rotate3d(1, 0, 0, 0deg);
}
.cover {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: linear-gradient(
0deg,
rgba(255, 255, 255, 0.6),
rgba(255, 255, 255, 0.6)
),
linear-gradient(
0deg,
rgba(255, 255, 255, 0.6),
rgba(255, 255, 255, 0.6)
);
background-position: top, bottom;
background-repeat: no-repeat;
}
.divider {
position: absolute;
width: 100%;
left: 0;
border-top: 1px solid #aaa;
border-bottom: 1px solid #aaa;
}
</style>
</head>
<body>
<div class="picker-container">
<section class="col-wrapper-father">
<section class="col-wrapper">
<ul id="wheel" class="wheel-list"></ul>
<div class="cover"></div>
<div class="divider"></div>
</section>
</section>
</div>
</body>
<script>
const SCROLL_CONTENT_HEIGHT = 300;
let radius = 2000; // 半径--console.log(Math.PI*2*radius/LINE_HEIGHT)=>估算最多可能多少条,有较大误差,半径2000应该够用了,不够就4000
const LINE_HEIGHT = 36; // 文字行高
// 反正切=>得到弧度再转换为度数,这个度数是单行文字所占有的。
let singleDeg = 2 * ((Math.atan(LINE_HEIGHT / 2 / radius) * 180) / Math.PI);
function getInitWheelItemTransform(indexNum) {
// 初始化时转到父页面传递的下标所对应的选中的值
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const rotate3dValue = getMoveWheelItemTransform(indexNum * LINE_HEIGHT);
return `${rotate3dValue} translateZ(${radius}px)`;
}
function getMoveWheelItemTransform(move) {
// 初始化时转到父页面传递的下标所对应的选中的值
const indexNum = Math.round(move / LINE_HEIGHT);
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const wheelItemDeg = indexNum * singleDeg;
return `rotateX(${wheelItemDeg}deg)`;
}
// class为col-wrapper的style样式: 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
const getWrapperStyle = () => ({
height: `${2 * radius}px`,
// 居中: 1/2直径 - 1/2父页面高度
transform: `translateY(-${radius - SCROLL_CONTENT_HEIGHT / 2}px)`,
});
// 当父元素(class为col-wrapper), 定位是relative, 高度是直径: 2 * radius, 子页面想要居中, top: (1/2直径)-(1/2*一行文字高度)
const circleTop = radius - LINE_HEIGHT / 2 + "px"; // 很重要!!!
// col-wrapper的子元素 => 参照一般居中的做法,[50%*父页面的高度(整个圆的最大高度是直径)]-居中内容块(文本的行高)的一半高度
const getCoverStyle = () => {
return {
backgroundSize: `100% ${circleTop}`,
};
};
// col-wrapper的子元素
const getListTop = () => ({
top: `${circleTop}`,
height: LINE_HEIGHT + "px",
lineHeight: LINE_HEIGHT + "px",
});
// col-wrapper的子元素 => 应该也是参照居中的做法(注意减去两条边框线)
const getDividerStyle = () => ({
top: `calc(${circleTop} - 2px)`,
height: LINE_HEIGHT - 2 + "px",
});
const arr = Array.from(
{ length: 100 },
(_, i) => `<li class="li-${i}">${i}</li>`
);
const str = `${arr.join("")}`;
wheel.innerHTML = str;
// 设置文字块的高度+垂直居中
wheel.style.height = getListTop().height;
wheel.style.lineHeight = getListTop().height;
wheel.style.top = getListTop().top;
document.getElementsByClassName('col-wrapper')[0].style.height = getWrapperStyle().height;
document.getElementsByClassName('col-wrapper')[0].style.transform = getWrapperStyle().transform;
document.getElementsByClassName("cover")[0].style.backgroundSize = getCoverStyle().backgroundSize;
document.getElementsByClassName("divider")[0].style.height = getDividerStyle().height;
document.getElementsByClassName("divider")[0].style.top = getDividerStyle().top;
arr.forEach((item, index) => {
const dom = document.getElementsByClassName(`li-${index}`)[0];
dom.style.position = 'absolute'; // 使用绝对定位, rotateX才有效果!!!(.wheel-item类的css已经写了)
dom.style.top = 0;
dom.style.transform = getInitWheelItemTransform(index);
dom.style.fontSize = '16px';
});
</script>
</html>
效果图:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>3d滚轮html示例--px转换为rem版本</title>
<style>
* {
margin: 0;
padding: 0;
}
html {
font-size: 10vw;
}
.picker-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
.pop-cover {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
}
.btn-box {
height: calc(40 * 10vw / 37.5);
background: rgb(112, 167, 99);
display: flex;
justify-content: space-between;
}
button {
background-color: rgba(0, 0, 0, 0);
border: none;
color: #fff;
padding: 0 calc(15 * 1rem / 37.5); /* 转换为rem */
font-size: calc(18 * 1rem / 37.5); /* 转换为rem */
}
.col-wrapper-father {
overflow: hidden;
}
ul,
li {
list-style: none;
padding: 0;
margin: 0;
}
.col-wrapper {
position: relative;
border: 1px solid #ccc;
text-align: center;
background: #fff;
}
.wheel-list,
#wheel {
position: absolute;
width: 100%;
transform-style: preserve-3d;
transform: rotate3d(1, 0, 0, 0deg);
}
.wheel-item {
backface-visibility: hidden;
position: absolute;
top: 0;
width: 100%;
border: 1px solid #eee;
}
.cover {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.6),
rgba(255, 255, 255, 0.9)
),
linear-gradient(
0deg,
rgba(255, 255, 255, 0.9),
rgba(255, 255, 255, 0.6)
);
background-position: top, bottom;
background-repeat: no-repeat;
}
.divider {
position: absolute;
width: 100%;
left: 0;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
</style>
</head>
<body>
<div class="picker-container">
<section class="pop-cover"></section>
<section>
<div class="btn-box">
<button>取消</button>
<button>确认</button>
</div>
<div class="col-wrapper-father">
<div class="col-wrapper">
<ul class="wheel-list" id="wheel"></ul>
<div class="cover"></div>
<div class="divider"></div>
</div>
</div>
</section>
</div>
</body>
<script>
const SCROLL_CONTENT_HEIGHT = 300;
let radius = 2000; // 半径--console.log(Math.PI*2*半径/LINE_HEIGHT)=>估算最多可能多少条,有较大误差,半径2000应该够用了,不够就4000
const LINE_HEIGHT = 36; // 文字行高
// 反正切=>得到弧度再转换为度数,这个度数是单行文字所占有的。
let singleDeg = 2 * ((Math.atan(LINE_HEIGHT / 2 / radius) * 180) / Math.PI);
const curIdx = 1;
const REM_UNIT = 37.5; // 为了转换为rem
const pxToRem = (pxNumber) => {
return Number(pxNumber / REM_UNIT) + "rem";
};
const radiusRem = pxToRem(radius);
function getInitWheelItemTransform(indexNum) {
// 初始化时转到父页面传递的下标所对应的选中的值
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const rotate3dValue = getMoveWheelItemTransform(indexNum * LINE_HEIGHT);
return `${rotate3dValue} translateZ(${radiusRem})`;
}
function getMoveWheelItemTransform(move) {
// 初始化时转到父页面传递的下标所对应的选中的值
const indexNum = Math.round(move / LINE_HEIGHT);
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const wheelItemDeg = indexNum * singleDeg;
console.log(wheelItemDeg, "wheelItemDeg");
return `rotateX(${wheelItemDeg}deg)`;
}
// col-wrapper的父元素, 限制滚动区域的高度,内容正常显示(col-wrapper多余的部分截掉不显示)
const getWrapperFatherStyle = () => {
return {
height: pxToRem(SCROLL_CONTENT_HEIGHT),
};
};
// class为col-wrapper的style样式: 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
const getWrapperStyle = () => ({
height: `${pxToRem(2 * radius)}`,
// 居中: 1/2直径 - 1/2父页面高度
transform: `translateY(-${pxToRem(radius - SCROLL_CONTENT_HEIGHT / 2)})`,
});
// 当父元素(class为col-wrapper), 定位是relative, 高度是直径: 2 * radius, 子页面想要居中, top: (1/2直径)-(1/2*一行文字高度)
// const circleTop = pxToRem(radius - LINE_HEIGHT / 2); // 很重要!!!
const circleTop = `calc(50% - ${pxToRem(LINE_HEIGHT / 2)})`; // 和上面的等价
// col-wrapper的子元素 => 3d滚轮的内容区域样式--useRef=wheel的元素样式
const getListTop = () => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT),
lineHeight: pxToRem(LINE_HEIGHT),
});
// col-wrapper的子元素 => 参照一般居中的做法,[50%*父页面的高度(整个圆的最大高度是直径)]-居中内容块(文本的行高)的一半高度
const getCoverStyle = () => {
return {
backgroundSize: `100% ${circleTop}`,
};
};
// col-wrapper的子元素 => 应该也是参照居中的做法(注意减去两条边框线)
const getDividerStyle = () => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT),
});
const arr = Array.from(
{ length: 100 },
(_, i) => `<li class="wheel-item li-${i}">${i}</li>`
);
const str = `${arr.join("")}`;
wheel.innerHTML = str;
// 设置wheel的height和top, 让它和divider一样在picker内容的正中间!!!
wheel.style.height = getListTop().height; // 文本高度
wheel.style.lineHeight = getListTop().lineHeight; // 文本行高
wheel.style.top = getListTop().top; // 垂直居中
document.getElementsByClassName("col-wrapper-father")[0].style.height =
getWrapperFatherStyle().height;
document.getElementsByClassName("col-wrapper")[0].style.height =
getWrapperStyle().height;
document.getElementsByClassName("col-wrapper")[0].style.transform =
getWrapperStyle().transform;
document.getElementsByClassName("cover")[0].style.backgroundSize =
getCoverStyle().backgroundSize;
document.getElementsByClassName("divider")[0].style.height =
getDividerStyle().height;
document.getElementsByClassName("divider")[0].style.top =
getDividerStyle().top;
arr.forEach((item, index) => {
const dom = document.getElementsByClassName(`li-${index}`)[0];
// dom.style.position = 'absolute'; // 使用绝对定位, rotateX才有效果!!!(.wheel-item类的css已经写了)
// dom.style.top = 0;
dom.style.transform = getInitWheelItemTransform(index);
dom.style.fontSize = pxToRem(16);
});
</script>
</html>
效果图:
注意: 不要使用box-shadow代替border, box-shadow很消耗浏览器性能, 时间长的话动画效果将不能执行!!!
使用html文件查看效果(未转换为rem, 还是使用px计算的, 不过逻辑都差不多
效果图:
vue文件部分:
<script setup lang="ts">
import { computed, ref, Ref } from 'vue';
import _ from 'lodash';
interface IProps {
selected?: number | string;
value: boolean;
values: number[];
cuIdx: number;
}
interface IEmits {
(e: 'update:value', arg1: boolean): void;
(e: 'select', arg1: number): void;
}
const props = defineProps<IProps>()
const emit = defineEmits<IEmits>()
let radius = 2000; // 半径--console.log(Math.PI*2*radius/LINE_HEIGHT)=>估算最多可能多少条,有较大误差,半径2000应该够用了,不够就4000
const LINE_HEIGHT = 36; // 文字行高
// 反正切=>得到弧度再转换为度数,这个度数是单行文字所占有的。
let singleDeg = 2 * ((Math.atan((LINE_HEIGHT / 2) / radius) * 180) / Math.PI);
const REM_UNIT = 37.5; // px转化为rem需要的除数
const SCROLL_CONTENT_HEIGHT = 300; // 有效滑动内容高度
const pxToRem = (pxNumber) => {
return Number(pxNumber / REM_UNIT) + 'rem'
}
const heightRem = pxToRem(LINE_HEIGHT); // picker的每一行的高度--单位rem
const lineHeightRem = pxToRem(LINE_HEIGHT); // picker的每一行的文字行高--单位rem
const radiusRem = pxToRem(radius); // 半径--单位rem
const { value, values, cuIdx } = (props); // 解构props, 得到需要使用来自父页面传入的数据
const pickerContainer = ref() as Ref<any>;
const wheel = ref() as Ref<any>;
// col-wrapper的父元素, 限制滚动区域的高度,内容正常显示(col-wrapper多余的部分截掉不显示)
const getWrapperFatherStyle = computed(() => {
return {
height: pxToRem(SCROLL_CONTENT_HEIGHT),
}
})
// class为col-wrapper的style样式: 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
const getWrapperStyle = computed(() => ({
height: pxToRem(2 * radius),
// 居中: 1/2直径 - 1/2父页面高度
transform: `translateY(-${pxToRem(radius - SCROLL_CONTENT_HEIGHT / 2)}`
}));
// 当父元素(class为col-wrapper), 定位是relative, 高度是直径: 2 * radius, 子页面想要居中, top: (1/2直径)-(1/2*一行文字高度)
const circleTop = pxToRem(radius - (LINE_HEIGHT / 2)); // 很重要!!!
// col-wrapper的子元素 => 3d滚轮的内容区域样式--ref=wheel的元素样式
const getListTop = computed(() => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT)
}))
// col-wrapper的子元素 => 参照一般居中的做法,[50%*父页面的高度(整个圆的最大高度是直径)]-居中内容块(文本的行高)的一半高度
const getCoverStyle = computed(() => {
return {
backgroundSize: `100% ${circleTop}`,
}
})
// col-wrapper的子元素 => 应该也是参照居中的做法(注意减去两条边框线)
const getDividerStyle = computed(() => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT),
}))
function initWheelItemDeg(index) {// 初始化时转到父页面传递的下标所对应的选中的值
// 滑到父页面传的当前选中的下标cuIdx处
const num = -1 * index + Number(cuIdx)
const transform = getInitWheelItemTransform(num)
// 当前的下标
return {
transform: transform,
height: heightRem,
lineHeight: lineHeightRem
};
}
/**
* 1、translate3d
在浏览器中,y轴正方向垂直向下,x轴正方向水平向右,z轴正方向指向外面。
z轴越大离我们越近,即看到的物体越大。z轴说物体到屏幕的距离。
*
*/
function getInitWheelItemTransform(indexNum) {// 初始化时转到父页面传递的下标所对应的选中的值
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const rotate3dValue = getMoveWheelItemTransform(indexNum * LINE_HEIGHT);
return `${rotate3dValue} translateZ(${radiusRem})`
}
function getMoveWheelItemTransform(move) {// 初始化时转到父页面传递的下标所对应的选中的值
const indexNum = Math.round(move / LINE_HEIGHT);
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const wheelItemDeg = indexNum * singleDeg
return `rotateX(${wheelItemDeg}deg)`
}
</script>
<template>
<Teleport to="body">
<div class="picker-container">
<div :ref="pickerContainer">
<transition name="myOpacity">
<section class="pop-cover" v-show="value"></section>
</transition>
<transition name="myPopup">
<section v-if="value">
<div class="btn-box">
<button>取消</button><button>确认</button>
</div>
<div class="col-wrapper-father" :style="getWrapperFatherStyle">
<div class="col-wrapper" :style="getWrapperStyle">
<ul class="wheel-list" :style="getListTop" :ref="wheel">
<li class="wheel-item" v-for="(item, index) in values"
:style="initWheelItemDeg(index)">
{{ item }}</li>
</ul>
<div class="cover" :style="getCoverStyle"></div>
<div class="divider" :style="getDividerStyle"></div>
</div>
</div>
</section>
</transition>
</div>
</div>
</Teleport>
</template>
<style lang="scss" scoped>
@import './Picker.scss';
</style>
Picker.scss文件:
@import "./common.scss";
.picker-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
// transition动画部分
.myOpacity-enter,
.myOpacity-leave-to {
opacity: 0;
}
.myOpacity-enter-active,
.myOpacity-leave-active {
transition: all 0.5s ease;
}
.myPopup-enter,
.myPopup-leave-to {
transform: translateY(100px);
}
.myPopup-enter-active,
.myPopup-leave-active {
transition: all 0.5s ease;
}
// 透明遮罩
.pop-cover {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
// 确认 取消按钮box
.btn-box {
height: pxToRem(40px);
background: rgb(112, 167, 99);
display: flex;
justify-content: space-between;
font-size: pxToRem(16px);
& button {
background-color: rgba(0, 0, 0, 0);
border: none;
color: #fff;
}
}
.col-wrapper-father {
overflow: hidden;
}
//overflow: hidden=>截掉多余的部分,显示弹窗内容部分
ul,
li {
list-style: none;
padding: 0;
margin: 0;
}
// 为了方便掌握重点样式,简单的就直接一行展示,其他的换行展示,方便理解
.col-wrapper {
position: relative;
border: 1px solid #ccc;
text-align: center;
background: #fff;
&>.wheel-list {
position: absolute;
width: 100%;
transform-style: preserve-3d;
transform: rotate3d(1, 0, 0, 0deg);
.wheel-item {
backface-visibility: hidden;
position: absolute;
left: 0;
top: 0;
width: 100%;
font-size: pxToRem(16px);
}
}
&>.cover {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: linear-gradient(0deg, rgba(white, 0.6), rgba(white, 0.6)), linear-gradient(0deg,
rgba(white, 0.6),
rgba(white, 0.6));
background-position: top, bottom;
background-repeat: no-repeat;
}
&>.divider {
position: absolute;
width: 100%;
left: 0;
border-top: 1px solid #cccccc;
border-bottom: 1px solid #cccccc;
}
}
}
common.scss文件:
@import './variables.scss';
@function pxToRem($px) {
$item: $pxToRemItem;
@return $px/$item+rem;
}
variables.scss文件:
$pxToRemItem: 37px;
1-2 touch事件
1-2-1. 开始滑动的时候:
1) 获取并保存手指点击的位置
finger.startY = ev.targetTouches[0].pageY; // 获取手指开始点击的位置
2) 保存手指上一次的滑动距离
finger.prevMove = finger.currentMove; // 保存手指上一次的滑动距离
3) 保存手指开始滑动的时间
finger.startTime = Date.now(); // 保存手指开始滑动的时间
function listenerTouchStart(ev) {
ev.stopPropagation();
isInertial.value = false; // 初始状态没有惯性滚动
finger.startY = ev.targetTouches[0].pageY; // 获取手指开始点击的位置
finger.prevMove = finger.currentMove; // 保存手指上一次的滑动距离
finger.startTime = Date.now(); // 保存手指开始滑动的时间
}
1-2-2. 滑动过程中:
1) 获取当前手指的位置
const nowStartY = ev.targetTouches[0].pageY;
2) 计算此次滑动的距离:
s1 = finger.startY - nowStartY
3) 指滑动总距离s:
finger.currentMove = s1 + 前一次滑动的距离
4) 使用getMoveWheelItemTransform函数获取transform
let wheelDom = wheel.value || document.getElementsByClassName('wheel-list')[0]
if (wheelDom) {
wheelDom.style.transform = getMoveWheelItemTransform(finger.currentMove);
}
function listenerTouchMove(ev) {
ev.stopPropagation();
// startY: 开始滑动的touch目标的pageY: ev.targetTouches[0].pageY减去
const nowStartY = ev.targetTouches[0].pageY; // 获取当前手指的位置
// finger.startY - nowStart为此次滑动的距离, 再加上上一次滑动的距离finger.prevMove, 路程总长: (finger.startY - nowStartY) + finger.prevMove
finger.currentMove = (finger.startY - nowStartY) + finger.prevMove;
let wheelDom = wheel.value || document.getElementsByClassName('wheel-list')[0]
if (wheelDom) {
wheelDom.style.transform = getMoveWheelItemTransform(finger.currentMove);
}
}
1-2-3. 结束滑动时
1) 获取结束时手指的位置
const _endY = ev.changedTouches[0].pageY; // 获取结束时手指的位置
2) 获取结束时间
const _entTime = Date.now(); // 获取结束时间
3) 通过v = (s初始-s结束) / t公式, 求速度v
// 速度v可能是正数, 也可能是负数
const v = (finger.startY - _endY) / (_entTime - finger.startTime);// 滚动完毕求移动速度 v = (s初始-s结束) / t
const absV = Math.abs(v); // 正数的速度
4) 调用xx函数inertia
// Math.round(absV / v) => 值为+1或者-1
// absV为正数的速度
// target: 为0表示停止滑动
inertia({ start: absV, position: Math.round(absV / v), target: 0 });
function listenerTouchEnd(ev) {
ev.stopPropagation();
const _endY = ev.changedTouches[0].pageY; // 获取结束时手指的位置
const _entTime = Date.now(); // 获取结束时间
const v = (finger.startY - _endY) / (_entTime - finger.startTime);// 滚动完毕求移动速度 v = (s初始-s结束) / t
const absV = Math.abs(v);
isInertial.value = true;// 最好惯性滚动,才不会死板
animate.start(() => inertia({ start: absV, position: Math.round(absV / v), target: 0 }));// Math.round(absV / v)=>+/-1
}
1-3. inertia函数分析:
1-3-1. 获取这段事件走的位移
公式: S = (+/-)vt + 1/2at^2 + s1
const move = (position * start * FRESH_TIME) + (0.5 * a * Math.pow(FRESH_TIME, 2)) + finger.currentMove;
1-3-2. 求末速度
公式: v末 = (+/-)v初 + at
const newStart = (position * start) + (a * FRESH_TIME);
1-3-3. 用新变量保存最后的移动距离(并进行数据边界处理)
let actualMove = move; // 最后的滚动距离
使用actualMove的目的是后面"数据边界滑动控制"需要使用move判断(所以move值不能随意重新赋值), actualMove就能够根据实际情况重新赋值
// 已经到达目标
// 当滑到第一个或者最后一个picker数据的时候, 不要滑出边界
// 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
const minIdx = 0 - cuIdx;
const maxIdx = values.length - 1 - cuIdx;
if (Math.abs(newStart) >= Math.abs(target)) {
if (Math.round(move / LINE_HEIGHT) < minIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的第一个文本处
actualMove = minIdx * LINE_HEIGHT;
}
else if (Math.round(move / LINE_HEIGHT) >= maxIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的最后一个文本处
actualMove = maxIdx * LINE_HEIGHT;
}
if (wheelDom) wheelDom.style.transition = 'transform 700ms cubic-bezier(0.19, 1, 0.22, 1)';
}
1-3-4. finger.currentMove保存最后的actualMove的值
// 为了touch事件(开始滑动的时候使用)
finger.currentMove = actualMove;
1-3-5. 使用css3的transition和transform实现动画效果
1) transition部分:
wheelDom.style.transition = 'transform 700ms cubic-bezier(0.19, 1, 0.22, 1)';
2) transform部分
wheelDom.style.transform = getMoveWheelItemTransform(actualMove);
1-3-6. animate.stop(), 滑动结束
animate.stop(); // 不写也行, animate做了防抖处理
1) Animate的原理
核心是requestAnimationFrame和cancelAnimationFrame, 用法类似setTimeout或者clearTimeout
开始: this.timer = requestAnimationFrame(fn);
结束:
cancelAnimationFrame(this.timer);
this.timer = null; // 注意重置this.timer为null
function Animate () {
return this.timer;
}
Animate.prototype.start = function (fn) {
if (!fn) {
throw new Error('需要执行函数');
}
if (this.timer) {
this.stop();
}
this.timer = requestAnimationFrame(fn);
}
Animate.prototype.stop = function () {
if (!this.timer) {
return;
}
cancelAnimationFrame(this.timer);
this.timer = null;
}
export default Animate;
2) Animatede优点:
性能优于定时器, 且做了防抖处理
防抖和节流的区别?
1. 防抖: n秒后再次执行该事件, 若n秒内被重复触发, 则重新计时
2. 节流: n秒内只运行一次, 若在n秒内重新触发, 只有一次生效
3. 一般会用到闭包函数
比如利用setTimeout和闭包函数封装成防抖和节流函数, 代码如下:
function debounce(fn, delay) { // 防抖
let timer = null;
return function() {
if(timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.call(this);
}, delay);
}
}
function throttle(fn, delay) { // 节流
let flag = true;
return function() {
if(flag) {
setTimeout(() => {
fn.call(this);
flag = true;
}, delay);
}
flag = false;
}
}
1-4. inertia函数可优化的几点:
1) 数据边界滑动控制
当滑到第一个数据之外, 要强制滑到第一个, 不要超出边界;
当滑到最后一个数据之外, 要强制滑回到最后一个数据那里;
代码同[ 1-3-3. 用新变量保存最后的移动距离(并进行数据边界处理) ]
// 已经到达目标
// 当滑到第一个或者最后一个picker数据的时候, 不要滑出边界
// 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
const minIdx = 0 - cuIdx;
const maxIdx = values.length - 1 - cuIdx;
if (Math.abs(newStart) >= Math.abs(target)) {
if (Math.round(move / LINE_HEIGHT) < minIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的第一个文本处
actualMove = minIdx * LINE_HEIGHT;
}
else if (Math.round(move / LINE_HEIGHT) >= maxIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的最后一个文本处
actualMove = maxIdx * LINE_HEIGHT;
}
if (wheelDom) wheelDom.style.transition = 'transform 700ms cubic-bezier(0.19, 1, 0.22, 1)';
}
2) 尽量成对使用animate.start和animate.stop()
虽然animate做了防抖处理, 但是为了代码的阅读性, 明确开始和结束的代码要展示出来
1-5 inertia函数的代码:
/**用户结束滑动,应该慢慢放慢,最终停止。从而需要 a(加速度)
* @param start 开始速度(注意是正数) @param position 速度方向,值: 正负1--向上是+1,向下是-1 @param target 结束速度
*/
function inertia({ start, position, target }) {
if (start <= target || !isInertial.value) {
animate.stop();
finger.prevMove = finger.currentMove;
getSelectValue(finger.currentMove);// 得到选中的当前下标
return;
}
// 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
const minIdx = 0 - cuIdx;
const maxIdx = values.length - 1 - cuIdx;
// 这段时间走的位移 S = vt + 1/2at^2 + s1;
const move = (position * start * FRESH_TIME) + (0.5 * a * Math.pow(FRESH_TIME, 2)) + finger.currentMove;
const newStart = (position * start) + (a * FRESH_TIME);// 根据求末速度公式: v末 = (+/-)v初 + at
let actualMove = move; // 最后的滚动距离
let wheelDom = wheel.value || document.getElementsByClassName('wheel-list')[0]
if (wheelDom) {
wheelDom.style.transition = '';
}
// 已经到达目标
// 当滑到第一个或者最后一个picker数据的时候, 不要滑出边界
if (Math.abs(newStart) >= Math.abs(target)) {
if (Math.round(move / LINE_HEIGHT) < minIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的第一个文本处
actualMove = minIdx * LINE_HEIGHT;
}
else if (Math.round(move / LINE_HEIGHT) >= maxIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的最后一个文本处
actualMove = maxIdx * LINE_HEIGHT;
}
if (wheelDom) wheelDom.style.transition = 'transform 700ms cubic-bezier(0.19, 1, 0.22, 1)';
}
// finger.currentMove赋值是为了点击确认的时候可以使用=>获取选中的值
finger.currentMove = actualMove;
if (wheelDom) wheelDom.style.transform = getMoveWheelItemTransform(actualMove);
animate.stop(); // 结束触发, 不写的话叶没有太大问题, 因为animate有做防抖处理
}
二. 使用picker组件页面
<template>
<div id="app">
<!-- <router-view/> -->
<ul>
<li>
<span>{{selected}}</span>
</li>
</ul>
<ios-select @select="getSelectValue" :cuIdx="cuIdx"
:values="values"
v-model="show" class="picker"></ios-select>
</div>
</template>
<script>
import iosSelect from './views/iosSelect/components/SelectColumn';
export default {
components :{
iosSelect
},
data() {
return {
selected:'',
cuIdx: 1,
show: true,
values: [
999,1,452,153,4,5,6,7,8999,9,10,
11,12,13,14,15,16,17,18,19,20,
],
}
},
mounted() {
//获取屏幕宽度(viewport)
let htmlWidth = document.documentElement.clientWidth ||
document.body.clientWidth;
console.log(htmlWidth);
//获取htmlDom
let htmlDom = document.getElementsByTagName('html')[0];
//设置html的font-size
htmlDom.style.fontSize = htmlWidth/10+'px';
window.addEventListener('resize',(e)=>{
let htmlWidth = document.documentElement.clientWidth ||
document.body.clientWidth;
console.log(htmlWidth);
//获取htmlDom
let htmlDom = document.getElementsByTagName('html')[0];
//设置html的font-size
htmlDom.style.fontSize = htmlWidth/10+'px';
});
},
methods: {
getSelectValue(value) {
this.selected = value;
}
}
}
</script>
<style lang="scss">
@function px2rem($px) {
$item: 37.5px;
@return $px/$item+rem;
}
* {
margin: 0;
padding: 0;
}
body ,html{
width: 100%;
}
.picker {
}
</style>
三. 使用requestAnimationFrame让动画更流畅
因为是浏览器方法, 比setInterval或者setTimeout效果更好
anmate.js
export default class Animate {
constructor() {
this.timer = null;
}
start = (fn) => {
if (!fn) {
throw new Error('需要执行函数');
}
if (this.timer) {
this.stop();
}
this.timer = requestAnimationFrame(fn);
};
stop = () => {
if (!this.timer) {
return;
}
cancelAnimationFrame(this.timer);
this.timer = null;
};
}
四. 扩展: 使用vue3语法:
1. 在shims-vue.d.ts中declare:
declare module '*.vue' {
import { ComponentOptions, DefineComponent } from 'vue'
const component: ComponentOptions<{},{},any> | DefineComponent<{},{},any>
export default component
}
2. <script>标签写了setup, lang=ts
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onUnmounted, nextTick, Ref } from 'vue';
import Animate from '../utils/animate';
import _ from 'lodash';
interface IProps {
selected?: number | string;
value: boolean;
values: number[];
cuIdx: number;
}
interface IEmits {
(e: 'update:value', arg1: boolean): void;
(e: 'select', arg1: number): void;
}
interface IFinger {
startY: number;
startTime: number;
currentMove: number;
prevMove: number;
}
type ICurrentIndex = number;
const props = defineProps<IProps>()
const emit = defineEmits<IEmits>()
const a = -0.003; // 加速度
let radius = 2000; // 半径--console.log(Math.PI*2*radius/LINE_HEIGHT)=>估算最多可能多少条,有较大误差,半径2000应该够用了,不够就4000
const LINE_HEIGHT = 36; // 文字行高
const FRESH_TIME = 1000 / 60;// 动画帧刷新的频率大概是1000 / 60
// 反正切=>得到弧度再转换为度数,这个度数是单行文字所占有的。
let singleDeg = 2 * ((Math.atan((LINE_HEIGHT / 2) / radius) * 180) / Math.PI);
const REM_UNIT = 37.5; // px转化为rem需要的除数
const SCROLL_CONTENT_HEIGHT = 300; // 有效滑动内容高度
const pxToRem = (pxNumber) => {
return Number(pxNumber / REM_UNIT) + 'rem'
}
const heightRem = pxToRem(LINE_HEIGHT); // picker的每一行的高度--单位rem
const lineHeightRem = pxToRem(LINE_HEIGHT); // picker的每一行的文字行高--单位rem
const radiusRem = pxToRem(radius); // 半径--单位rem
const { values, cuIdx } = props; // 解构props, 得到需要使用来自父页面传入的数据
// props的value只有第一次渲染才传数据过来了, 其他时候没有实时更新, 使用计算属性获取一下, 此变量能够控制组件的显示与隐藏, 以及消失时transition的动画
const isShow = computed(()=> props.value);
// 存储手指滑动的数据
const finger = reactive<IFinger>({
startY: 0,
startTime: 0, // 开始滑动时间(单位:毫秒)
currentMove: 0,
prevMove: 0,
});
const currentIndex = ref<ICurrentIndex>(0);
const pickerContainer = ref() as Ref<any>;
const wheel = ref() as Ref<any>;
let isInertial = ref<boolean>(false); // 是否正在惯性滑动
// col-wrapper的父元素, 限制滚动区域的高度,内容正常显示(col-wrapper多余的部分截掉不显示)
const getWrapperFatherStyle = computed(() => {
return {
height: pxToRem(SCROLL_CONTENT_HEIGHT),
}
})
// class为col-wrapper的style样式: 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
const getWrapperStyle = computed(() => ({
height: pxToRem(2 * radius),
// 居中: 1/2直径 - 1/2父页面高度
transform: `translateY(-${pxToRem(radius - SCROLL_CONTENT_HEIGHT / 2)})`
}));
// 当父元素(class为col-wrapper), 定位是relative, 高度是直径: 2 * radius, 子页面想要居中, top: (1/2直径)-(1/2*一行文字高度)
const circleTop = pxToRem(radius - (LINE_HEIGHT / 2)); // 很重要!!!
// col-wrapper的子元素 => 3d滚轮的内容区域样式--ref=wheel的元素样式
const getListTop = computed(() => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT)
}))
// col-wrapper的子元素 => 参照一般居中的做法,[50%*父页面的高度(整个圆的最大高度是直径)]-居中内容块(文本的行高)的一半高度
const getCoverStyle = computed(() => {
return {
backgroundSize: `100% ${circleTop}`,
}
})
// col-wrapper的子元素 => 应该也是参照居中的做法(注意减去两条边框线)
const getDividerStyle = computed(() => ({
top: `calc(${circleTop} - ${pxToRem(0)})`,
height: pxToRem(LINE_HEIGHT),
}))
const animate = new Animate()
function initWheelItemDeg(index) {// 初始化时转到父页面传递的下标所对应的选中的值
// 滑到父页面传的当前选中的下标cuIdx处
const num = -1 * index + Number(cuIdx)
const transform = getInitWheelItemTransform(num)
// 当前的下标
return {
transform: transform,
height: heightRem,
lineHeight: lineHeightRem
};
}
/**
* 1、translate3d
在浏览器中,y轴正方向垂直向下,x轴正方向水平向右,z轴正方向指向外面。
z轴越大离我们越近,即看到的物体越大。z轴说物体到屏幕的距离。
*
*/
function getInitWheelItemTransform(indexNum) {// 初始化时转到父页面传递的下标所对应的选中的值
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const rotate3dValue = getMoveWheelItemTransform(indexNum * LINE_HEIGHT);
return `${rotate3dValue} translateZ(calc(${radiusRem} / 1))`
}
function getMoveWheelItemTransform(move) {// 初始化时转到父页面传递的下标所对应的选中的值
const indexNum = Math.round(move / LINE_HEIGHT);
// 滑动的角度: 该行文字下标 * 一行文字对应的角度
const wheelItemDeg = indexNum * singleDeg
return `rotateX(${wheelItemDeg}deg)`
}
function listenerTouchStart(ev) {
ev.stopPropagation();
isInertial.value = false; // 初始状态没有惯性滚动
finger.startY = ev.targetTouches[0].pageY; // 获取手指开始点击的位置
finger.prevMove = finger.currentMove; // 保存手指上一次的滑动距离
finger.startTime = Date.now(); // 保存手指开始滑动的时间
}
function listenerTouchMove(ev) {
ev.stopPropagation();
// startY: 开始滑动的touch目标的pageY: ev.targetTouches[0].pageY减去
const nowStartY = ev.targetTouches[0].pageY; // 获取当前手指的位置
// finger.startY - nowStart为此次滑动的距离, 再加上上一次滑动的距离finger.prevMove, 路程总长: (finger.startY - nowStartY) + finger.prevMove
finger.currentMove = (finger.startY - nowStartY) + finger.prevMove;
let wheelDom = wheel.value || document.getElementsByClassName('wheel-list')[0]
if (wheelDom) {
wheelDom.style.transform = getMoveWheelItemTransform(finger.currentMove);
}
}
function listenerTouchEnd(ev) {
ev.stopPropagation();
const _endY = ev.changedTouches[0].pageY; // 获取结束时手指的位置
const _entTime = Date.now(); // 获取结束时间
const v = (finger.startY - _endY) / (_entTime - finger.startTime);// 滚动完毕求移动速度 v = (s初始-s结束) / t
const absV = Math.abs(v);
isInertial.value = true;// 最好惯性滚动,才不会死板
inertia({ start: absV, position: Math.round(absV / v), target: 0 });// Math.round(absV / v)=>+/-1
}
/**用户结束滑动,应该慢慢放慢,最终停止。从而需要 a(加速度)
* @param start 开始速度(注意是正数) @param position 速度方向,值: 正负1--向上是+1,向下是-1 @param target 结束速度
*/
function inertia({ start, position, target }) {
if (start <= target || !isInertial.value) {
animate.stop();
finger.prevMove = finger.currentMove;
getSelectValue(finger.currentMove);// 得到选中的当前下标
return;
}
// 这段时间走的位移 S = (+/-)vt + 1/2at^2 + s1;
const move = (position * start * FRESH_TIME) + (0.5 * a * Math.pow(FRESH_TIME, 2)) + finger.currentMove;
const newStart = (position * start) + (a * FRESH_TIME);// 根据求末速度公式: v末 = (+/-)v初 + at
let actualMove = move; // 最后的滚动距离
let wheelDom = wheel.value || document.getElementsByClassName('wheel-list')[0]
// 已经到达目标
// 当滑到第一个或者最后一个picker数据的时候, 不要滑出边界
// 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
const minIdx = 0 - cuIdx;
const maxIdx = values.length - 1 - cuIdx;
if (Math.abs(newStart) >= Math.abs(target)) {
if (Math.round(move / LINE_HEIGHT) < minIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的第一个文本处
actualMove = minIdx * LINE_HEIGHT;
}
else if (Math.round(move / LINE_HEIGHT) >= maxIdx) {
// 让滚动在文字区域内,超出区域的滚回到边缘的最后一个文本处
actualMove = maxIdx * LINE_HEIGHT;
}
if (wheelDom) wheelDom.style.transition = 'transform 700ms cubic-bezier(0.19, 1, 0.22, 1)';
}
// finger.currentMove赋值是为了点击确认的时候可以使用=>获取选中的值
finger.currentMove = actualMove;
if (wheelDom) wheelDom.style.transform = getMoveWheelItemTransform(actualMove);
animate.stop();
// animate.start(() => inertia.bind({ start: newStart, position, target }));
}
// 滚动时及时获取最新的当前下标--因为在初始化的时候减去了,所以要加上cuIdx,否则下标会不准确
function getSelectValue(move) {
const idx = Math.round(move / LINE_HEIGHT) + Number(cuIdx)
currentIndex.value = idx
return idx
}
function sure(ev) {// 点击确认按钮
getSelectValue(finger.currentMove);
emit('select', values[currentIndex.value]);
close();
}
function close() {
nextTick(() => {
emit('update:value', false);
});
}// 点击取消按钮
onMounted(() => {
const dom = pickerContainer.value || document.getElementsByClassName('picker-container')[0];
try {
dom.addEventListener('touchstart', listenerTouchStart, false);
dom.addEventListener('touchmove', listenerTouchMove, false);
dom.addEventListener('touchend', listenerTouchEnd, false);
} catch (error) {
console.log(error);
}
})
onUnmounted(() => {
const dom = pickerContainer.value || document.getElementsByClassName('picker-container')[0];
dom.removeEventListener('touchstart', listenerTouchStart, false);
dom.removeEventListener('touchmove', listenerTouchMove, false);
dom.removeEventListener('touchend', listenerTouchEnd, false);
})
</script>
<template>
<Teleport to="body">
<div class="picker-container">
<div :ref="pickerContainer">
<transition name="myOpacity">
<section class="pop-cover" @touchstart="close" v-show="isShow"></section>
</transition>
<transition name="myPopup">
<section v-if="isShow">
<div class="btn-box">
<button @touchstart="close">取消</button><button @touchstart="sure">确认</button>
</div>
<div class="col-wrapper-father" :style="getWrapperFatherStyle">
<div class="col-wrapper" :style="getWrapperStyle">
<ul class="wheel-list" :style="getListTop" :ref="wheel">
<li class="wheel-item" v-for="(item, index) in values"
:style="initWheelItemDeg(index)">
{{ item }}</li>
</ul>
<div class="cover" :style="getCoverStyle"></div>
<div class="divider" :style="getDividerStyle"></div>
</div>
</div>
</section>
</transition>
</div>
</div>
</Teleport>
</template>
<style lang="scss" scoped>
@import './Picker.scss';
</style>
<Teleport to="body">的效果是appendChild到body中, 不在id为app的div里面, 如图所示:
注意事项: 此种方式写的逻辑最全, 当props没有拿到父页面更新后的传参, 使用计算属性获取:
// props的value只有第一次渲染才传数据过来了, 其他时候没有实时更新, 使用计算属性获取一下, 此变量能够控制组件的显示与隐藏, 以及消失时transition的动画
const isShow = computed(()=> props.value);
父页面调用:
<script setup lang="ts">
import { reactive, toRefs, ref } from 'vue'
import ThreeDPicker from './ThreeDPicker.vue'
const state = reactive({
selected: '',
cuIdx: 3,
pickerList: [
999, 1, 452, 153, 4, 5, 6, 7, 8999, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
],
});
function getSelectValue(value) {
state.selected = value;
}
const isShow = ref(true);
const {
selected,
cuIdx,
pickerList,
} = toRefs(state);
function onShow(val) {
isShow.value = val
}
</script>
<template>
<div>
<ul>
<li>
<span>{{ selected }}</span>
</li>
</ul>
<ThreeDPicker @select="getSelectValue" :cuIdx="cuIdx" :values="pickerList" v-model:value="isShow" class="picker" />
<button @click="onShow(true)">出现picker</button>
</div>
</template>
<style lang="scss"></style>
3. 若是<script>写了lang=ts, 但是没有写setup
<template>
<Teleport to="body">
<div class="picker-container">
<div :ref="pickerContainer">
<transition name="myOpacity">
<section class="pop-cover" @touchstart="close" v-show="value"></section>
</transition>
<transition name="myPopup">
<section v-if="value">
<div class="btn-box"><button @touchstart="close">取消</button><button @touchstart="sure">确认</button>
</div>
<section class="aaa">
<div class="gg" :style="ggStyle">
<div class="col-wrapper" :style="getWrapperHeight">
<ul class="wheel-list" :style="getListTop" :ref="wheel">
<li class="wheel-item" v-for="(item, index) in values"
:style="initWheelItemDeg(index)" :key="'wheel-item-' + index">
{{ item }}</li>
</ul>
<div class="cover" :style="getCoverStyle"></div>
<div class="divider" :style="getDividerStyle"></div>
</div>
</div>
</section>
</section>
</transition>
</div>
</div>
</Teleport>
</template>
<script lang="ts">
import {
computed,
reactive, ref, onMounted,
onUnmounted, nextTick, Ref
} from 'vue';
import Animate from '../utils/animate';
interface IProps {
selected?: number | string;
value: boolean;
values: number[];
cuIdx: number;
}
interface IEmits {
(e: 'update:value', arg1: boolean): void;
(e: 'select', arg1: number): void;
}
interface IFinger {
startY: number;
startTime: number;
currentMove: number;
prevMove: number;
}
type ICurrentIndex = number;
export default {
props: ['selected', 'value', 'values', 'cuIdx'],
emits: ['update:value', 'select'],
setup(props: IProps, { emit }: { emit: IEmits }) {
const a = -0.003; // 加速度
let radius = 2000; // 半径--console.log(Math.PI*2*radius/lineHeight)=>估算最多可能多少条,有较大误差,半径2000应该够用了,不够就4000
const lineHeight = 36; // 文字行高
let isInertial = false; // 是否正在惯性滑动
// 反正切=>得到弧度再转换为度数,这个度数是单行文字所占有的。
let singleDeg = 2 * ((Math.atan((lineHeight / 2) / radius) * 180) / Math.PI);
const remUnit = 37.5; // px转rem的倍数
const {
value, values, cuIdx,
} = (props)
const finger = reactive<IFinger>({
startY: 0,
startTime: 0, // 开始滑动时间(单位:毫秒)
currentMove: 0,
prevMove: 0,
});
const currentIndex: Ref<ICurrentIndex> = ref(0);
const pickerContainer = ref('pickerContainer') as Ref;
const wheel = ref('wheel') as Ref;
// 限制滚动区域的高度,内容正常显示--以下皆未使用this,所以可以用箭头函数简化写法
const ggStyle = computed(() => ({ maxHeight: `${radius / remUnit}rem`, transform: `translateY(-${(radius - 300 / 2) / remUnit}rem` }))
// 3d滚轮的内容区域样式--ref=wheel的元素样式
const getListTop = computed(() => ({ top: `${(radius - (lineHeight / 2)) / remUnit}rem`, height: `${lineHeight / remUnit}rem` }))
// 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
const getWrapperHeight = computed(() => ({ height: `${2 * radius / remUnit}rem` }))
// 参照一般居中的做法,50%*父页面的高度(整个圆的最大高度是直径)-居中内容块(文本的行高)的一半高度
const getCoverStyle = computed(() => ({ backgroundSize: `100% ${(radius - (lineHeight / 2)) / remUnit}rem` }))
// 应该也是参照居中的做法
const getDividerStyle = computed(() => ({ top: `${(radius - (lineHeight / 2)) / remUnit}rem`, height: `${(lineHeight - 2) / remUnit}rem` }))
const animate = new Animate()
function initWheelItemDeg(index) {// 初始化时转到父页面传递的下标所对应的选中的值
return {
transform: `rotate3d(1, 0, 0, ${(-1 * index
+ Number(cuIdx)) * singleDeg}deg) translate3d(0, 0, ${radius / remUnit}rem)`,
height: `${lineHeight / remUnit}rem`, lineHeight: `${lineHeight / remUnit}rem`
};
}
function listenerTouchStart(ev) {
ev.stopPropagation(); ev.preventDefault();
isInertial = false; // 初始状态没有惯性滚动
finger.startY = ev.targetTouches[0].pageY; // 获取手指开始点击的位置
finger.prevMove = finger.currentMove;
finger.startTime = Date.now();
}
function listenerTouchMove(ev) {
ev.stopPropagation(); ev.preventDefault();
finger.currentMove = (finger.startY - ev.targetTouches[0].pageY) + finger.prevMove;
if (wheel.value) {
wheel.value.style.transform = `rotate3d(1, 0, 0, ${(finger.currentMove / lineHeight) * singleDeg}deg)`;
}
}
function listenerTouchEnd(ev) {
ev.stopPropagation(); ev.preventDefault();
const _endY = ev.changedTouches[0].pageY;
const _entTime = Date.now();
const v = (finger.startY - _endY) / (_entTime - finger.startTime);// 滚动完毕求移动速度 v = (s初始-s结束) / t
const absV = Math.abs(v);
isInertial = true;// 最好惯性滚动,才不会死板
inertia(absV, Math.round(absV / v), 0);// Math.round(absV / v)=>+/-1
}
/**用户结束滑动,应该慢慢放慢,最终停止。从而需要 a(加速度)
* @param start 开始速度 @param position 速度方向,值: 正负1--向上是+1,向下是-1 @param target 结束速度
*/
function inertia(start, position, target) {
if (start <= target || !isInertial) {
animate.stop();
finger.prevMove = finger.currentMove;
getSelectValue(finger.currentMove);// 得到选中的当前下标
return;
}
// 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
const minIdx = 0 - cuIdx;
const maxIdx = values.length - 1 - cuIdx;
const freshTime = 1000 / 60;// 动画帧刷新的频率大概是1000 / 60
// 这段时间走的位移 S = vt + 1/2at^2 + s1;
const move = (position * start * freshTime) + (0.5 * a * Math.pow(freshTime, 2)) + finger.currentMove;
const newStart = (position * start) + (a * freshTime);// 根据求末速度公式: v末 = (+/-)v初 + at
let moveDeg = Math.round(move / lineHeight) * singleDeg;// 正常的滚动角度
let actualMove = move; // 最后的滚动距离
if (wheel.value) {
wheel.value.style.transition = '';
}
// 已经到达目标
if (Math.abs(newStart) <= Math.abs(target)) {
// 让滚动在文字区域内,超出区域的滚回到边缘的第一个文本处
if (Math.round(move / lineHeight) < minIdx) {
moveDeg = minIdx * singleDeg;
actualMove = minIdx * lineHeight;
} else if (Math.round(move / lineHeight) > maxIdx) {
moveDeg = maxIdx * singleDeg;
actualMove = maxIdx * lineHeight;
}
if (wheel.value) {
wheel.value.style.transition = 'transform 700ms cubic-bezier(0.19, 1, 0.22, 1)';
}
}
// finger.currentMove赋值是为了点击确认的时候可以使用=>获取选中的值
finger.currentMove = actualMove;
if (wheel.value) {
wheel.value.style.transform = `rotate3d(1, 0, 0, ${moveDeg}deg)`;
}
animate.start(() => inertia.bind(newStart, position, target));
}
// 滚动时及时获取最新的当前下标--因为在初始化的时候减去了,所以要加上cuIdx,否则下标会不准确
function getSelectValue(move) { currentIndex.value = Math.round(move / lineHeight) + Number(cuIdx); }
function sure(ev) {// 点击确认按钮
getSelectValue(finger.currentMove);
nextTick(() => {
emit('select', values[currentIndex.value]);
close();
});
}
function close() {
nextTick(() => { emit('update:value', false); });
}// 点击取消按钮
onMounted(() => {
// const dom = getCurrentInstance().vnode.el
const dom = pickerContainer.value
dom.addEventListener('touchstart', listenerTouchStart, false);
dom.addEventListener('touchmove', listenerTouchMove, false);
dom.addEventListener('touchend', listenerTouchEnd, false);
})
onUnmounted(() => {
const dom = pickerContainer.value
dom.removeEventListener('touchstart', listenerTouchStart, false);
dom.removeEventListener('touchmove', listenerTouchMove, false);
dom.removeEventListener('touchend', listenerTouchEnd, false);
})
return {
value,
values,
sure,
close,
ggStyle,
getListTop,
getWrapperHeight,
getCoverStyle,
getDividerStyle,
initWheelItemDeg,
wheel,
pickerContainer,
}
}
}
</script>
<style lang="scss" scoped>@import './Picker.scss';</style>
效果和上面的一样