- TreeDPicker.tsx文件
原理就不想赘述了, 想了解的话, 网址在:
使用vue写一个picker插件,使用3d滚轮的原理_vue3中支持3d picker选择器插件-CSDN博客
import React, { useEffect, useRef, Ref, useState } from "react";
import Animate from "../utils/animate";
import _ from "lodash";
import "./Picker.scss";
import * as ReactDOM from "react-dom";
import MyTransition from "./MyTransition";
interface IProps {
selected?: number | string;
cuIdx: number;
pickerArr: string[]|number[];
isShow: boolean;
setIsShow: (arg1: boolean) => void;
setSelectedValue: (arg1: number|string) => void;
}
interface IFinger {
startY: number;
startTime: number;
currentMove: number;
prevMove: number;
}
type ICurrentIndex = number;
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 TreeDPicker = (props: IProps) => {
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 { cuIdx, pickerArr, isShow, setIsShow, setSelectedValue } = props; // 解构props, 得到需要使用来自父页面传入的数据
const[pickerIsShow, setPickerIsShow] = useState(props.isShow)
useEffect(() => {
setPickerIsShow(isShow)
}, [isShow])
// 存储手指滑动的数据
const finger0 = useRef<IFinger>({
startY: 0,
startTime: 0, // 开始滑动时间(单位:毫秒)
currentMove: 0,
prevMove: 0,
});
const finger = _.get(finger0, "current") || {};
const currentIndex = useRef<ICurrentIndex>(0);
const pickerContainer = useRef() as Ref<any>;
const wheel = useRef() as Ref<any>;
let isInertial = useRef<boolean>(false); // 是否正在惯性滑动
// 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); // 很重要!!!
// col-wrapper的子元素 => 3d滚轮的内容区域样式--useRef=wheel的元素样式
const getListTop = () => ({
top: circleTop,
height: pxToRem(LINE_HEIGHT),
});
// col-wrapper的子元素 => 参照一般居中的做法,[50%*父页面的高度(整个圆的最大高度是直径)]-居中内容块(文本的行高)的一半高度
const getCoverStyle = () => {
return {
backgroundSize: `100% ${circleTop}`,
};
};
// col-wrapper的子元素 => 应该也是参照居中的做法(注意减去两条边框线)
const getDividerStyle = () => ({
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.current = 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 =
_.get(wheel, "current") ||
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.current = true; // 最好惯性滚动,才不会死板
animate.start(() => 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.current) {
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 =
_.get(wheel, "current") ||
document.getElementsByClassName("wheel-list")[0];
// 已经到达目标
// 当滑到第一个或者最后一个picker数据的时候, 不要滑出边界
// 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
const minIdx = 0 - cuIdx;
const maxIdx = pickerArr.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.current = idx;
return idx;
}
function sure() {
// 点击确认按钮
getSelectValue(finger.currentMove);
setSelectedValue(pickerArr[currentIndex.current]);
setTimeout(() => {
close();
}, 0);
}
function close() {
setTimeout(() => {
setPickerIsShow(false);
// 延迟关闭, 因为MyTransition需要这段事件差执行动画效果
setTimeout(() => {
setIsShow(false)
}, 500);
}, 0);
} // 点击取消按钮
useEffect(() => {
const dom =
_.get(pickerContainer, "current") ||
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);
}
return () => {
const dom =
_.get(pickerContainer, "current") ||
document.getElementsByClassName("picker-container")[0];
dom.removeEventListener("touchstart", listenerTouchStart, false);
dom.removeEventListener("touchmove", listenerTouchMove, false);
dom.removeEventListener("touchend", listenerTouchEnd, false);
};
}, [_.get(pickerContainer, "current")]);
return ReactDOM.createPortal(
<div className="picker-container">
<div ref={pickerContainer}>
{isShow+''}
<MyTransition name="myPopup" transitionShow={pickerIsShow}>
{isShow && (
<section className="pop-cover" onClick={close}></section>
)}
</MyTransition>
<MyTransition name="myOpacity" transitionShow={pickerIsShow}>
{isShow && (
<section>
<div className="btn-box">
<button onClick={close}>取消</button>
<button onClick={sure}>确认</button>
</div>
<div
className="col-wrapper-father"
style={getWrapperFatherStyle()}
>
<div className="col-wrapper" style={getWrapperStyle()}>
<ul className="wheel-list" style={getListTop()} ref={wheel}>
{_.map(pickerArr, (item, index) => {
return (
<li
className="wheel-item"
style={initWheelItemDeg(index)}
key={"wheel-list-"+index}
>
{item+''}
</li>
);
})}
</ul>
<div className="cover" style={getCoverStyle()}></div>
<div className="divider" style={getDividerStyle()}></div>
</div>
</div>
</section>
)}
</MyTransition>
</div>
</div>,
document.body
);
};
export default TreeDPicker;
-
scss文件:
@import "./common.scss";
.picker-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;// transition动画部分
.myOpacity-enter,
.myOpacity-leave-to {
opacity: 0;
// 因为picker滚动区域有过transform, 这里也写transform的话会导致本不该滚动的地方滚动了
}.myOpacity-enter-active,
.myOpacity-leave-active {
opacity: 1;
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%; border: 1px solid #eee; 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 #ccc; border-bottom: 1px solid #ccc; }
}
} -
transition组件(之前写了一篇文章有提到):