有限列表无限循环滚动方案

1,190 阅读4分钟

问题描述

在开发小工具计时器时,涉及到了倒计时的选择时、分功能,实现无论用户向哪个方向滚动,时间选择器都能无限循环的效果

image.png

思考内容

1、怎么实现有限列表无限循环

2、怎么封装成通用的组件,应开放哪些参数为可配置

可行性方案分析

有限列表无限循环方案

方案一: 使用JS实现,指定列表list的起点a,终点b,手指向上滑动时只考虑b,当b进入视窗,判断当前列表长度是否为2倍list.length,如果是,删除前一个列表,否则不用操作,然后在后面插入一个列表; 手指向下滑动时只考虑a,当a进入视窗,判断当前列表长度是否为2倍list.length,如果是,删除后一个列表,否则不用操作,然后在前面插入一个列表。

方案二: 利用css3 transform-style:preserve-3d;perspective: number;这两个属性实现,通过将列表中所有的元素更改其transform属性使它们在空间中拼接成环。元素拼接需要计算设置列表所有元素在空间中的位置,我们需要找到计算公式来使之通用化。

比如我们要实现元素绕着Y轴在空间中拼成环,如下图空间俯视图所示,利用rotateY 旋转元素摆放角度,旋转角度 = 360deg/ 元素总数量 * (当前元素下标+1),再利用 translateZ在Z轴上移动,移动距离 = (元素宽度/ 2) / (tan(360deg/ 元素总数量/ 2)):

image.png

解决方案分析

由于最终要实现的效果需要列表呈现出弧度,方案一是在平面上的无限循环、当元素进入视窗时,还需要为其添加3d属性,增加了工作量;而方案二实现的已经呈现出有弧度的效果,直接修改其rotateX/rotateY即可实现绕着X轴/Y轴旋转的效果。所以最终选择方案二。

封装通用的组件

1、利用React.Children.map配合React.cloneElement 为所有子元素添加一个className,为子元素添加3d效果和设置初始位置,为第一个子元素添加ref props,并按照它们在空间中的位置,使用计算公式进行旋转和移动。
2、利用ref 获取第一个子元素dom元素,拿到其宽度和高度,用来设置在Z轴上的移动距离
3、通过onRotateDegChange props 可以添加在移动端的触摸动画,通过更改translateZ属性 / perspective props 也可以拉近拉远视角

使用方式如下图: image.png

代码:

// components/3dView/index.tsx
import React, { useEffect, useState, useRef } from 'react';
import classnames from 'classnames';
import styles from './index.less';

interface I3dView {
  children: any;
  width?: number;
  height?: number;
  imgCount?: number;
  wrapperClassName?: string;
  rotateDirection?: 'X' | 'Y';
  perspective?: number;
  onRotateDegChange: () => void;
}

export default function View(props: I3dView) {
  const {
    children,
    wrapperClassName,
    width = 150,
    height = 150,
    rotateDirection = 'X',
    perspective = 2000,
  } = props;
  const imgCount = children.length;
  const perViewElementRadians = 360 / imgCount;
  const viewElementTranslateZ = width / 2 / Math.tan((perViewElementRadians * (180 / Math.PI)) / 2);
  const viewStageStyle = { perspective: `${perspective}px` };
  const viewWrapStyle = {
    width,
    height,
  };
  const [childrenWithProps, setChildrenWithProps] = useState<any>(children);
  const childRef = useRef<any>();

  useEffect(() => {
    setChildrenWithProps(
      React.Children.map(children, (child, index) => {
        const oldClassName = child.props.className;
        let newChildProps: any = {
          className: `${oldClassName} view-element`,
          style: {
            transform: `rotate${rotateDirection}((${index} * ${perViewElementRadians})deg) translateZ(${viewElementTranslateZ}px)`,
          },
        };
        newChildProps =
          index === 0
            ? {
                ...newChildProps,
                ref: childRef,
              }
            : newChildProps;

        if (child.type) {
          return React.cloneElement(child, newChildProps);
        }
        return child;
      }),
    );
  }, [children]);

  return (
    <div className={styles.view_container_wrapper}>
      <div className={classnames('view-container', wrapperClassName)}>
        {/* 舞台层 */}
        <div className="view-stage" style={viewStageStyle}>
          {/* 控制层 */}
          <div className="view-control">
            {/* 元素层 */}
            <div className="view-wrap" style={viewWrapStyle}>
              {childrenWithProps}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

/** components/3dView/index.less **/
.view_container_wrapper {
  // 自动计算
  :global {
    .view-container {
      // // 自定义填写
      @width: 150px;
      @height: 150px;
      @imgCount: 24;
      @rotateDirection: X;
      @perspective: 2000px;

      @perDivDeg: 360deg / @imgCount;
      @translateZPx: (@width / 2) / (tan(@perDivDeg / 2));
      position: relative;

      .dTrans(@n, @i: 1) when (@i <=@n)and( @rotateDirection = X ) {
        &:nth-child(@{i}) {
          background: hsla(@i * 30, 50%, @i * 1.5%, @i * 0.1);
          transform: rotateX((@i * @perDivDeg)) translateZ(@translateZPx);
        }

        .dTrans(@n, (@i+1));
      }

      .dTrans(@n, @i: 1) when (@i <=@n)and( @rotateDirection = Y ) {
        &:nth-child(@{i}) {
          background: hsla(@i * 30, 50%, @i * 1.5%, @i * 0.1);
          transform: rotateY((@i * @perDivDeg)) translateZ(@translateZPx);
        }

        .dTrans(@n, (@i+1));
      }

      .view-stage {
        position: relative;
        width: 800px; // TODO1
        height: 400px;
        margin: 0 auto;
        perspective: @perspective; // TODO2
        transform-style: preserve-3d;

        .view-control {
          position: relative;
          width: 100%;
          height: 100%;
          transform-style: preserve-3d;
          transform: translateZ(-2000px) rotateX(0deg) rotateZ(0deg); // TODO3
          animation: rotateX 40s linear infinite;

          .view-wrap {
            position: absolute;
            width: @width;
            height: @height;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            transform-style: preserve-3d;

            .view-element {
              position: absolute;
              width: @width;
              height: @height;
              line-height: @height;
              text-align: center;
              font-size: 120px;
              top: 0;
              left: 0;
              transform-style: preserve-3d;
              transform-origin: 50% 50% 0px;
              backface-visibility: hidden;
              .dTrans(@imgCount);
            }
          }
        }
      }

      // @keyframes rotate {
      //   0% {
      //     transform: translateZ(-2000px) rotateY(0deg) rotateZ(10deg);
      //   }
      //   50% {
      //     transform: translateZ(-1000px) rotateY(-360deg) rotateZ(-10deg);
      //   }
      //   100% {
      //     transform: translateZ(-2000px) rotateY(-720deg) rotateZ(10deg);
      //   }
      // }
    }
  }
}
@keyframes rotateX {
  0% {
    -webkit-transform: translateZ(-2000px) rotateX(0deg) rotateZ(0deg);
    transform: translateZ(-2000px) rotateX(360deg) rotateZ(0deg);
  }
  100% {
    -webkit-transform: translateZ(-2000px) rotateX(360deg) rotateZ(0deg);
    transform: translateZ(-2000px) rotateX(0deg) rotateZ(0deg);
  }
}
@keyframes rotateY {
  0% {
    -webkit-transform: translateZ(-2000px) rotateY(0deg) rotateZ(0deg);
    transform: translateZ(-2000px) rotateY(0deg) rotateZ(0deg);
  }
  100% {
    -webkit-transform: translateZ(-2000px) rotateY(360deg) rotateZ(0deg);
    transform: translateZ(-2000px) rotateY(360deg) rotateZ(0deg);
  }
}