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

  1. 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;
  1. 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;
     }
    

    }
    }

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

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

相关推荐
new出一个对象5 小时前
uniapp接入BMapGL百度地图
javascript·百度·uni-app
你挚爱的强哥6 小时前
✅✅✅【Vue.js】sd.js基于jQuery Ajax最新原生完整版for凯哥API版本
javascript·vue.js·jquery
前端Hardy7 小时前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu10830189117 小时前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
小镇程序员10 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
疯狂的沙粒10 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪10 小时前
AJAX的基本使用
前端·javascript·ajax
力透键背10 小时前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M10 小时前
node.js第三方Express 框架
前端·javascript·node.js·express
weiabc10 小时前
学习electron
javascript·学习·electron