使用vue写一个picker插件,使用3d滚轮的原理

11 篇文章 0 订阅

一. 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>

效果和上面的一样

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值