• react写一个简单的3d滚轮picker组件


    1. TreeDPicker.tsx文件

    原理就不想赘述了, 想了解的话, 网址在:

    使用vue写一个picker插件,使用3d滚轮的原理_vue3中支持3d picker选择器插件-CSDN博客

    1. import React, { useEffect, useRef, Ref, useState } from "react";
    2. import Animate from "../utils/animate";
    3. import _ from "lodash";
    4. import "./Picker.scss";
    5. import * as ReactDOM from "react-dom";
    6. import MyTransition from "./MyTransition";
    7. interface IProps {
    8. selected?: number | string;
    9. cuIdx: number;
    10. pickerArr: string[]|number[];
    11. isShow: boolean;
    12. setIsShow: (arg1: boolean) => void;
    13. setSelectedValue: (arg1: number|string) => void;
    14. }
    15. interface IFinger {
    16. startY: number;
    17. startTime: number;
    18. currentMove: number;
    19. prevMove: number;
    20. }
    21. type ICurrentIndex = number;
    22. const a = -0.003; // 加速度
    23. let radius = 2000; // 半径--console.log(Math.PI*2*radius/LINE_HEIGHT)=>估算最多可能多少条,有较大误差,半径2000应该够用了,不够就4000
    24. const LINE_HEIGHT = 36; // 文字行高
    25. const FRESH_TIME = 1000 / 60; // 动画帧刷新的频率大概是1000 / 60
    26. // 反正切=>得到弧度再转换为度数,这个度数是单行文字所占有的。
    27. let singleDeg = 2 * ((Math.atan(LINE_HEIGHT / 2 / radius) * 180) / Math.PI);
    28. const REM_UNIT = 37.5; // px转化为rem需要的除数
    29. const SCROLL_CONTENT_HEIGHT = 300; // 有效滑动内容高度
    30. const TreeDPicker = (props: IProps) => {
    31. const pxToRem = (pxNumber) => {
    32. return Number(pxNumber / REM_UNIT) + "rem";
    33. };
    34. const heightRem = pxToRem(LINE_HEIGHT); // picker的每一行的高度--单位rem
    35. const lineHeightRem = pxToRem(LINE_HEIGHT); // picker的每一行的文字行高--单位rem
    36. const radiusRem = pxToRem(radius); // 半径--单位rem
    37. const { cuIdx, pickerArr, isShow, setIsShow, setSelectedValue } = props; // 解构props, 得到需要使用来自父页面传入的数据
    38. const[pickerIsShow, setPickerIsShow] = useState(props.isShow)
    39. useEffect(() => {
    40. setPickerIsShow(isShow)
    41. }, [isShow])
    42. // 存储手指滑动的数据
    43. const finger0 = useRef<IFinger>({
    44. startY: 0,
    45. startTime: 0, // 开始滑动时间(单位:毫秒)
    46. currentMove: 0,
    47. prevMove: 0,
    48. });
    49. const finger = _.get(finger0, "current") || {};
    50. const currentIndex = useRef<ICurrentIndex>(0);
    51. const pickerContainer = useRef() as Ref<any>;
    52. const wheel = useRef() as Ref<any>;
    53. let isInertial = useRef<boolean>(false); // 是否正在惯性滑动
    54. // col-wrapper的父元素, 限制滚动区域的高度,内容正常显示(col-wrapper多余的部分截掉不显示)
    55. const getWrapperFatherStyle = () => {
    56. return {
    57. height: pxToRem(SCROLL_CONTENT_HEIGHT),
    58. };
    59. };
    60. // class为col-wrapper的style样式: 滚轮的外包装理想样式--展示半径的内容可见,另外的半径隐藏
    61. const getWrapperStyle = () => ({
    62. height: pxToRem(2 * radius),
    63. // 居中: 1/2直径 - 1/2父页面高度
    64. transform: `translateY(-${pxToRem(radius - SCROLL_CONTENT_HEIGHT / 2)})`,
    65. });
    66. // 当父元素(class为col-wrapper), 定位是relative, 高度是直径: 2 * radius, 子页面想要居中, top: (1/2直径)-(1/2*一行文字高度)
    67. const circleTop = pxToRem(radius - LINE_HEIGHT / 2); // 很重要!!!
    68. // col-wrapper的子元素 => 3d滚轮的内容区域样式--useRef=wheel的元素样式
    69. const getListTop = () => ({
    70. top: circleTop,
    71. height: pxToRem(LINE_HEIGHT),
    72. });
    73. // col-wrapper的子元素 => 参照一般居中的做法,[50%*父页面的高度(整个圆的最大高度是直径)]-居中内容块(文本的行高)的一半高度
    74. const getCoverStyle = () => {
    75. return {
    76. backgroundSize: `100% ${circleTop}`,
    77. };
    78. };
    79. // col-wrapper的子元素 => 应该也是参照居中的做法(注意减去两条边框线)
    80. const getDividerStyle = () => ({
    81. top: `calc(${circleTop} - ${pxToRem(0)})`,
    82. height: pxToRem(LINE_HEIGHT),
    83. });
    84. const animate = new Animate();
    85. function initWheelItemDeg(index) {
    86. // 初始化时转到父页面传递的下标所对应的选中的值
    87. // 滑到父页面传的当前选中的下标cuIdx处
    88. const num = -1 * index + Number(cuIdx);
    89. const transform = getInitWheelItemTransform(num);
    90. // 当前的下标
    91. return {
    92. transform: transform,
    93. height: heightRem,
    94. lineHeight: lineHeightRem,
    95. };
    96. }
    97. /**
    98. * 1、translate3d
    99. 在浏览器中,y轴正方向垂直向下,x轴正方向水平向右,z轴正方向指向外面。
    100. z轴越大离我们越近,即看到的物体越大。z轴说物体到屏幕的距离。
    101. *
    102. */
    103. function getInitWheelItemTransform(indexNum) {
    104. // 初始化时转到父页面传递的下标所对应的选中的值
    105. // 滑动的角度: 该行文字下标 * 一行文字对应的角度
    106. const rotate3dValue = getMoveWheelItemTransform(indexNum * LINE_HEIGHT);
    107. return `${rotate3dValue} translateZ(calc(${radiusRem} / 1))`;
    108. }
    109. function getMoveWheelItemTransform(move) {
    110. // 初始化时转到父页面传递的下标所对应的选中的值
    111. const indexNum = Math.round(move / LINE_HEIGHT);
    112. // 滑动的角度: 该行文字下标 * 一行文字对应的角度
    113. const wheelItemDeg = indexNum * singleDeg;
    114. return `rotateX(${wheelItemDeg}deg)`;
    115. }
    116. function listenerTouchStart(ev) {
    117. ev.stopPropagation();
    118. isInertial.current = false; // 初始状态没有惯性滚动
    119. finger.startY = ev.targetTouches[0].pageY; // 获取手指开始点击的位置
    120. finger.prevMove = finger.currentMove; // 保存手指上一次的滑动距离
    121. finger.startTime = Date.now(); // 保存手指开始滑动的时间
    122. }
    123. function listenerTouchMove(ev) {
    124. ev.stopPropagation();
    125. // startY: 开始滑动的touch目标的pageY: ev.targetTouches[0].pageY减去
    126. const nowStartY = ev.targetTouches[0].pageY; // 获取当前手指的位置
    127. // finger.startY - nowStart为此次滑动的距离, 再加上上一次滑动的距离finger.prevMove, 路程总长: (finger.startY - nowStartY) + finger.prevMove
    128. finger.currentMove = finger.startY - nowStartY + finger.prevMove;
    129. let wheelDom =
    130. _.get(wheel, "current") ||
    131. document.getElementsByClassName("wheel-list")[0];
    132. if (wheelDom) {
    133. wheelDom.style.transform = getMoveWheelItemTransform(finger.currentMove);
    134. }
    135. }
    136. function listenerTouchEnd(ev) {
    137. ev.stopPropagation();
    138. const _endY = ev.changedTouches[0].pageY; // 获取结束时手指的位置
    139. const _entTime = Date.now(); // 获取结束时间
    140. const v = (finger.startY - _endY) / (_entTime - finger.startTime); // 滚动完毕求移动速度 v = (s初始-s结束) / t
    141. const absV = Math.abs(v);
    142. isInertial.current = true; // 最好惯性滚动,才不会死板
    143. animate.start(() => inertia({ start: absV, position: Math.round(absV / v), target: 0 })); // Math.round(absV / v)=>+/-1
    144. }
    145. /**用户结束滑动,应该慢慢放慢,最终停止。从而需要 a(加速度)
    146. * @param start 开始速度(注意是正数) @param position 速度方向,值: 正负1--向上是+1,向下是-1 @param target 结束速度
    147. */
    148. function inertia({ start, position, target }) {
    149. if (start <= target || !isInertial.current) {
    150. animate.stop();
    151. finger.prevMove = finger.currentMove;
    152. getSelectValue(finger.currentMove); // 得到选中的当前下标
    153. return;
    154. }
    155. // 这段时间走的位移 S = (+/-)vt + 1/2at^2 + s1;
    156. const move =
    157. position * start * FRESH_TIME +
    158. 0.5 * a * Math.pow(FRESH_TIME, 2) +
    159. finger.currentMove;
    160. const newStart = position * start + a * FRESH_TIME; // 根据求末速度公式: v末 = (+/-)v初 + at
    161. let actualMove = move; // 最后的滚动距离
    162. let wheelDom =
    163. _.get(wheel, "current") ||
    164. document.getElementsByClassName("wheel-list")[0];
    165. // 已经到达目标
    166. // 当滑到第一个或者最后一个picker数据的时候, 不要滑出边界
    167. // 因为在开始的时候加了父页面传递的下标,这里需要减去才能够正常使用
    168. const minIdx = 0 - cuIdx;
    169. const maxIdx = pickerArr.length - 1 - cuIdx;
    170. if (Math.abs(newStart) >= Math.abs(target)) {
    171. if (Math.round(move / LINE_HEIGHT) < minIdx) {
    172. // 让滚动在文字区域内,超出区域的滚回到边缘的第一个文本处
    173. actualMove = minIdx * LINE_HEIGHT;
    174. } else if (Math.round(move / LINE_HEIGHT) >= maxIdx) {
    175. // 让滚动在文字区域内,超出区域的滚回到边缘的最后一个文本处
    176. actualMove = maxIdx * LINE_HEIGHT;
    177. }
    178. if (wheelDom)
    179. wheelDom.style.transition =
    180. "transform 700ms cubic-bezier(0.19, 1, 0.22, 1)";
    181. }
    182. // finger.currentMove赋值是为了点击确认的时候可以使用=>获取选中的值
    183. finger.currentMove = actualMove;
    184. if (wheelDom)
    185. wheelDom.style.transform = getMoveWheelItemTransform(actualMove);
    186. animate.stop();
    187. // animate.start(() => inertia.bind({ start: newStart, position, target }));
    188. }
    189. // 滚动时及时获取最新的当前下标--因为在初始化的时候减去了,所以要加上cuIdx,否则下标会不准确
    190. function getSelectValue(move) {
    191. const idx = Math.round(move / LINE_HEIGHT) + Number(cuIdx);
    192. currentIndex.current = idx;
    193. return idx;
    194. }
    195. function sure() {
    196. // 点击确认按钮
    197. getSelectValue(finger.currentMove);
    198. setSelectedValue(pickerArr[currentIndex.current]);
    199. setTimeout(() => {
    200. close();
    201. }, 0);
    202. }
    203. function close() {
    204. setTimeout(() => {
    205. setPickerIsShow(false);
    206. // 延迟关闭, 因为MyTransition需要这段事件差执行动画效果
    207. setTimeout(() => {
    208. setIsShow(false)
    209. }, 500);
    210. }, 0);
    211. } // 点击取消按钮
    212. useEffect(() => {
    213. const dom =
    214. _.get(pickerContainer, "current") ||
    215. document.getElementsByClassName("picker-container")[0];
    216. try {
    217. dom.addEventListener("touchstart", listenerTouchStart, false);
    218. dom.addEventListener("touchmove", listenerTouchMove, false);
    219. dom.addEventListener("touchend", listenerTouchEnd, false);
    220. } catch (error) {
    221. console.log(error);
    222. }
    223. return () => {
    224. const dom =
    225. _.get(pickerContainer, "current") ||
    226. document.getElementsByClassName("picker-container")[0];
    227. dom.removeEventListener("touchstart", listenerTouchStart, false);
    228. dom.removeEventListener("touchmove", listenerTouchMove, false);
    229. dom.removeEventListener("touchend", listenerTouchEnd, false);
    230. };
    231. }, [_.get(pickerContainer, "current")]);
    232. return ReactDOM.createPortal(
    233. <div className="picker-container">
    234. <div ref={pickerContainer}>
    235. {isShow+''}
    236. <MyTransition name="myPopup" transitionShow={pickerIsShow}>
    237. {isShow && (
    238. <section className="pop-cover" onClick={close}></section>
    239. )}
    240. </MyTransition>
    241. <MyTransition name="myOpacity" transitionShow={pickerIsShow}>
    242. {isShow && (
    243. <section>
    244. <div className="btn-box">
    245. <button onClick={close}>取消</button>
    246. <button onClick={sure}>确认</button>
    247. </div>
    248. <div
    249. className="col-wrapper-father"
    250. style={getWrapperFatherStyle()}
    251. >
    252. <div className="col-wrapper" style={getWrapperStyle()}>
    253. <ul className="wheel-list" style={getListTop()} ref={wheel}>
    254. {_.map(pickerArr, (item, index) => {
    255. return (
    256. <li
    257. className="wheel-item"
    258. style={initWheelItemDeg(index)}
    259. key={"wheel-list-"+index}
    260. >
    261. {item+''}
    262. </li>
    263. );
    264. })}
    265. </ul>
    266. <div className="cover" style={getCoverStyle()}></div>
    267. <div className="divider" style={getDividerStyle()}></div>
    268. </div>
    269. </div>
    270. </section>
    271. )}
    272. </MyTransition>
    273. </div>
    274. </div>,
    275. document.body
    276. );
    277. };
    278. export default TreeDPicker;

    2. scss文件:

    1. @import "./common.scss";
    2. .picker-container {
    3. position: fixed;
    4. bottom: 0;
    5. left: 0;
    6. right: 0;
    7. // transition动画部分
    8. .myOpacity-enter,
    9. .myOpacity-leave-to {
    10. opacity: 0;
    11. // 因为picker滚动区域有过transform, 这里也写transform的话会导致本不该滚动的地方滚动了
    12. }
    13. .myOpacity-enter-active,
    14. .myOpacity-leave-active {
    15. opacity: 1;
    16. transition: all 0.5s ease;
    17. }
    18. .myPopup-enter,
    19. .myPopup-leave-to {
    20. transform: translateY(100px);
    21. }
    22. .myPopup-enter-active,
    23. .myPopup-leave-active {
    24. transition: all 0.5s ease;
    25. }
    26. // 透明遮罩
    27. .pop-cover {
    28. position: fixed;
    29. top: 0;
    30. left: 0;
    31. right: 0;
    32. height: 100vh;
    33. background: rgba(0, 0, 0, 0.5);
    34. z-index: -1;
    35. }
    36. // 确认 取消按钮box
    37. .btn-box {
    38. height: pxToRem(40px);
    39. background: rgb(112, 167, 99);
    40. display: flex;
    41. justify-content: space-between;
    42. font-size: pxToRem(16px);
    43. & button {
    44. background-color: rgba(0, 0, 0, 0);
    45. border: none;
    46. color: #fff;
    47. }
    48. }
    49. .col-wrapper-father {
    50. overflow: hidden;
    51. }
    52. //overflow: hidden=>截掉多余的部分,显示弹窗内容部分
    53. ul,
    54. li {
    55. list-style: none;
    56. padding: 0;
    57. margin: 0;
    58. }
    59. // 为了方便掌握重点样式,简单的就直接一行展示,其他的换行展示,方便理解
    60. .col-wrapper {
    61. position: relative;
    62. border: 1px solid #ccc;
    63. text-align: center;
    64. background: #fff;
    65. &>.wheel-list {
    66. position: absolute;
    67. width: 100%;
    68. transform-style: preserve-3d;
    69. transform: rotate3d(1, 0, 0, 0deg);
    70. .wheel-item {
    71. backface-visibility: hidden;
    72. position: absolute;
    73. left: 0;
    74. top: 0;
    75. width: 100%;
    76. border: 1px solid #eee;
    77. font-size: pxToRem(16px);
    78. }
    79. }
    80. &>.cover {
    81. position: absolute;
    82. left: 0;
    83. top: 0;
    84. right: 0;
    85. bottom: 0;
    86. background: linear-gradient(0deg, rgba(white, 0.6), rgba(white, 0.6)), linear-gradient(0deg,
    87. rgba(white, 0.6),
    88. rgba(white, 0.6));
    89. background-position: top, bottom;
    90. background-repeat: no-repeat;
    91. }
    92. &>.divider {
    93. position: absolute;
    94. width: 100%;
    95. left: 0;
    96. border-top: 1px solid #ccc;
    97. border-bottom: 1px solid #ccc;
    98. }
    99. }
    100. }

    3. transition组件(之前写了一篇文章有提到):

    react简单写一个transition动画组件然后在modal组件中应用-CSDN博客

  • 相关阅读:
    c++以exception_ptr传递异常
    MySQL中binlog备份脚本分享
    【7.21-26】代码源 - 【平方计数】【字典序最小】【“Z”型矩阵】
    嵌入式名工程师,为什么有些人月薪8K,而有些人年薪40K值得深思
    Web前端笔记1.0【Html详解,CSS详解】【Js待完善】
    从0开始写中国象棋-创建棋盘与棋子
    常见排序算法
    【ARM】CCI集成指导整理
    关键词搜索1688工厂数据API接口代码对接教程
    java毕业设计房屋中介网络平台Mybatis+系统+数据库+调试部署
  • 原文地址:https://blog.csdn.net/qq_42750608/article/details/133875227