问题描述
在开发小工具计时器时,涉及到了倒计时的选择时、分功能,实现无论用户向哪个方向滚动,时间选择器都能无限循环的效果
思考内容
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)):
解决方案分析
由于最终要实现的效果需要列表呈现出弧度,方案一是在平面上的无限循环、当元素进入视窗时,还需要为其添加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 也可以拉近拉远视角
使用方式如下图:
代码:
// 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);
}
}