静态效果图
功能
- 拖动后会默认居中当前最靠近中间元素
- 点击靠近中间位置的左右两个元素可以切换剧中元素
- 可选择hover时添加视频动画
代码
import React, { useState, MouseEvent, useMemo, useEffect } from 'react';
import { useEventListener, useThrottleFn } from 'ahooks';
import cx from './index.module.less';
interface Props {
imgWidth: number;
imgHeight: number;
}
interface ListItem {
img: string;
video: string;
index: number;
}
const imgList = [
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRc-daxzQsmo3cQ7M0TM4hOz3pX-1WKzB-V-w&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQXhun_JTTMmKDhvGv0vELHx41BS5hVL58mDA&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR1kno3P3RqG6qB-uJYBlglsznLVf4XLY_34A&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTz-VVabwdSkWiTFe4WTzj1WkD3ILkrEV0dQw&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRc-daxzQsmo3cQ7M0TM4hOz3pX-1WKzB-V-w&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQXhun_JTTMmKDhvGv0vELHx41BS5hVL58mDA&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR1kno3P3RqG6qB-uJYBlglsznLVf4XLY_34A&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTz-VVabwdSkWiTFe4WTzj1WkD3ILkrEV0dQw&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRc-daxzQsmo3cQ7M0TM4hOz3pX-1WKzB-V-w&usqp=CAU',
video: '',
},
{
img: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQXhun_JTTMmKDhvGv0vELHx41BS5hVL58mDA&usqp=CAU',
video: '',
},
];
export const Banner3D = ({ imgWidth = 400, imgHeight = 400 }: Props) => {
const [clientWidth, setClientWidth] = useState(document.documentElement.clientWidth);
const [mouseDrag, setMouseDrag] = useState(false);
const [mouseX, setMouseX] = useState(0);
const [rotateY, setRotateY] = useState(0);
const [listLength] = useState(imgList.length);
const [average] = useState(360 / listLength);
const [showSlowAnimation, setShowSlowAnimation] = useState(true);
const [activeIndex, setActiveIndex] = useState(0);
const list: ListItem[] = useMemo(() => {
return imgList.map((item, index) => {
return { ...item, index, rotateY: index * (360 / listLength) };
});
}, [imgList]);
useEventListener('resize', () => {
const { clientWidth } = document.documentElement;
setClientWidth(clientWidth);
});
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
setShowSlowAnimation(false);
setMouseDrag(true);
setMouseX(e.clientX);
};
const handleMouseUp = () => {
setMouseDrag(false);
setShowSlowAnimation(true);
setMouseX(0);
setRotateY(average * Math.round(rotateY / average));
};
const drag = (e: MouseEvent<Element, MouseEvent>) => {
const oldX = mouseX;
const newX = e.clientX;
if (oldX !== 0) {
let constance = newX - oldX;
setRotateY(rotateY - (180 * constance) / clientWidth);
}
setMouseX(newX);
};
const { run: dragThrottle } = useThrottleFn(drag, {
wait: 100,
});
const handleMouseMove = (e: any) => {
if (mouseDrag) dragThrottle(e);
};
const translateZ = Math.max(((imgWidth + 50) * (listLength / 2)) / Math.PI, clientWidth / 2);
const handleClick = (item: ListItem) => {
if (item.index - 1 === activeIndex || activeIndex - item.index === listLength - 1) setRotateY(rotateY - average);
if (item.index + 1 === activeIndex || item.index - activeIndex === listLength - 1) setRotateY(rotateY + average);
};
useEffect(() => {
if (mouseDrag) return;
if (rotateY % 360 === 0) {
setActiveIndex(0);
return;
}
const activeItem = list.find((item: any) => {
if (rotateY >= 0) {
return Math.round(Math.abs(item.rotateY)) === Math.round(Math.abs((rotateY % 360) - 360));
} else {
return Math.round(Math.abs(item.rotateY)) === Math.round(Math.abs(rotateY % 360));
}
});
if (activeItem) setActiveIndex(activeItem.index);
}, [rotateY, average]);
useEventListener('mouseup', handleMouseUp);
useEventListener('mousemove', handleMouseMove);
return (
<div className={cx('banner-container')} style={{ height: `${imgHeight}px` }} onMouseDown={handleMouseDown}>
<div
className={cx('box', { 'slow-animation': showSlowAnimation })}
style={{ width: `${imgWidth}px`, height: `${imgHeight}px`, transform: `rotateY(${rotateY}deg) ` }}>
{list.map(item => {
return (
<div
key={item.index}
onClick={() => {
handleClick(item);
}}
className={cx('img-box', `item${item.index}`, {
'near-active':
item.index === (activeIndex + 1 >= listLength ? 0 : activeIndex + 1) ||
item.index === (activeIndex - 1 >= 0 ? activeIndex - 1 : listLength - 1),
active: activeIndex === item.index,
})}
style={{
backgroundImage: `url('${item.img}')`,
transform: `rotateY(${item.index * (360 / listLength)}deg) translateZ(-${translateZ}px) rotateX(0deg)`,
}}>
{item.video && (
<video preload="auto" loop muted autoPlay>
<source src={item.video} />
</video>
)}
</div>
);
})}
</div>
</div>
);
};
export default Banner3D;
.banner-container{
width: 100%;
overflow: hidden;
user-select: none;
cursor: pointer;
max-width: 1920px;
margin: 0 auto;
min-width: 1350px;
perspective:2000px;
display: flex;
align-items: center;
justify-content: center;
.box{
transform-style: preserve-3d;
transform: rotateY(0deg);
transition: all 0.1s ease-out;
&.slow-animation{
transition: all 1s ease-out;
}
}
.img-box{
width: 100%;
height: 100%;
position: absolute;
overflow: hidden;
backface-visibility: hidden;
transform-origin: 50% 50%;
transform: rotateY(180deg);
transition-delay: 300ms;
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
border-radius: 4px;
img {
display: block;
width: 100%;
height: 100%;
user-select: none;
}
video{
display: none;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
visibility: hidden;
}
&.active,&.near-active:hover{
video{
display: block;
visibility: visible;
}
}
}
}