一、swiper简介
swiper是一款图片轮播插件,基本所有涉及图片轮播的相关功能都能通过swiper实现。
| 市面上常见的轮播插件 | 优点 | 缺点 |
|---|---|---|
| swiper | 覆盖了轮播常见的基本所有功能,也有相对较好的可扩展性,特别是在跨端上有着较好的兼容性,6以上版本支持ssr | 包体积大,冗杂,部分功能实现过于繁琐,api也很多,容易让人眼花缭乱 |
| slick | 一款很精致的插件,功能调用api直观易懂,自定义可扩展也很丰富 demo网站:kenwheeler.github.io/slick/ | 依赖jquery,可能不支持ssr |
| react-slick | slick的react版本,最流行,易定制 demo网站:react-slick.neostack.com/docs/exampl… |
二、相关常用参数说明及完整swiper组件
常用参数如下
完整组件代码如下
import React, { ReactNode, useEffect, useRef, useContext } from 'react'
import { useSpring, animated, useSpringRef, useChain } from '@react-spring/web'
import { Swiper, SwiperSlide, SwiperClass } from '@/components/swiper'
import { throttle } from 'lodash'
import styles from './index.module.less'
import classNames from 'classnames'
import { CLIENT_TYPE } from '@/constants'
import { Common } from '@/../typings/common'
import { IContext } from 'ssr-types-react'
import { STORE_CONTEXT } from '@/../build/create-context'
React.useLayoutEffect = React.useEffect
interface HomeBannerPropsRule {
animations?: ReactNode[] // 轮播内文字动效(titleAnimation)
bannerImgs: ReactNode[] // 轮播主体内容
onBannerChange?: (index: number) => void // 切页后返回当前页下标
current?: number // 当前显示的轮播页
changeCurrent?: (index: number) => void // 更改轮播页(强制),与onBannerChange不冲突
havePagination?: boolean // 是否使用默认的分页器(透明横线)
direction?: 'horizontal' | 'vertical' | undefined // 轮播方向,水平|垂直|默认(水平)
mousewheel?: boolean | any // 是否支持鼠标滚轮切页
noSwiping?: boolean // 是否支持鼠标拖拽切页
loop?: boolean // 是否无限轮播
pagination?: ReactNode // 自定义分页器
navigation?: { prev: ReactNode, next: ReactNode, nextTop?: number | string, nextRight?: number | string, prevTop?: number | string, prevLeft?: number | string } // 自定义导航
paginationClassname?: string // 分页自定义类名
prevClassname?: string // 上一页箭头自定义类名
nextClassname?: string // 下一页箭头自定义类名
slidesPerView?: number | 'auto' // 当前展示的轮播页数
spaceBetween?: number // 轮播图之间间隔
className?: string // 自定义Swiper类名
centeredSlides?: boolean // active元素是否居中
effect?: 'fade' | 'coverflow' | 'cube' | 'flip' | 'slide'
animationToggle?: 'on' | 'off'
nested?: boolean // 是否是子级swiper
speed?: number // 切页速度
autoplay?: any // 自动切页参数
haveNestedScrollbar?: boolean // 是否含有嵌套可滚动内容
grabCursor?: boolean // 鼠标是否显示手抓
onScrollDirection?: (direction: number) => void // 滚动方向,1 为向下滚动,-1 为向上滚动
shouldPaginationAnimate?: boolean // 分页器是否需要动画
autoHeight?: boolean // 是否自动撑高
onSlideChangeTransitionEnd?: (index: number) => void
onRef?: (silder?: SwiperClass) => void
}
type ActiveSlide = Element & { swiperSlideSize: number }
const Slider = (props: HomeBannerPropsRule) => {
const {
animations = [],
bannerImgs = [],
onBannerChange,
current = 0,
changeCurrent,
havePagination = false,
direction = 'horizontal',
mousewheel = false,
noSwiping = false,
loop = false,
pagination,
navigation,
paginationClassname,
prevClassname,
nextClassname,
slidesPerView = 'auto',
spaceBetween = 0,
className,
centeredSlides = true,
effect = undefined,
animationToggle = 'on',
nested = true,
haveNestedScrollbar,
speed = 0,
autoplay = false,
grabCursor = false,
shouldPaginationAnimate = false,
onScrollDirection = () => { },
autoHeight = false,
onSlideChangeTransitionEnd = () => { },
onRef = () => { }
} = props
const swiperRef = useRef<SwiperClass>()
const { state } = useContext<IContext<Common>>(STORE_CONTEXT)
const init = (swiper: SwiperClass) => {
swiperRef.current = swiper
onRef(swiperRef.current)
}
useEffect(() => {
// 通过监听current状态,强制进行切页
if (swiperRef.current && swiperRef.current.realIndex !== current) {
loop ? swiperRef.current.slideToLoop(current) : swiperRef.current.slideTo(current)
}
}, [current])
const navLeftRef = useSpringRef()
const { leftController } = useSpring({
ref: navLeftRef,
from: { translateX: 140, opacity: 0, leftController: 0 },
leftController: animationToggle === 'on' ? 1 : 0,
config: { duration: 1000 },
delay: 1000
})
const navRightRef = useSpringRef()
const { rightController } = useSpring({
ref: navRightRef,
from: { translateX: -140, opacity: 0, rightController: 0 },
rightController: animationToggle === 'on' ? 1 : 0,
config: { duration: 1000 },
delay: 1000
})
const paginationRef = useSpringRef()
const { paginationController } = useSpring({
ref: paginationRef,
from: { translateY: 140, opacity: 0, paginationController: 0 },
paginationController: animationToggle === 'on' ? 1 : 0,
config: { duration: 800 }
})
useChain(animationToggle === 'on' ? [paginationRef, navLeftRef, navRightRef] : [paginationRef, navLeftRef, navRightRef].reverse(), animationToggle === 'on' ? [0, 0.1, 0.1] : [0, 0, 0.1])
let beforeScrollTop = 0
const handleScroll = throttle((event) => {
const afterScrollTop = event.target.scrollTop
const delta = afterScrollTop - beforeScrollTop
onScrollDirection(delta > 0 ? 1 : -1)
beforeScrollTop = afterScrollTop
}, 200)
const handleContainerWheel = (element) => {
const scrollHeight = element.scrollHeight
const slideSize = element.swiperSlideSize
const scrollDifferenceTop = scrollHeight - slideSize
const handleWheel = (event) => {
const scrollDifference = scrollHeight - slideSize - element.scrollTop
// Scroll wheel browser compatibility
const delta = event.wheelDelta || -1 * event.deltaY
// Enable scrolling if at edges
const spos = delta < 0 ? 0 : scrollDifferenceTop
const parseScrollDifference = Number.parseInt(`${scrollDifference}`)
const parseSpos = Number.parseInt(`${spos}`)
console.log(scrollDifference, spos)
if (parseScrollDifference !== parseSpos) {
console.log('stopPropagation')
event.stopPropagation()
} else {
// 边缘释放参数关闭时才生效
if (!swiperRef?.current?.params?.mousewheel?.releaseOnEdges) {
// 滑动到最后一个幻灯片的底部,继续监听事件
if (parseScrollDifference === 0 && parseSpos === 0 && current === swiperRef?.current?.slides?.length - 1) {
return
}
// TODO 滑动到第一个幻灯片的顶部,暂不处理,暂时没有首屏有滚动条的情况
}
element.removeEventListener('wheel', handleWheel)
element.removeEventListener('scroll', handleScroll)
}
}
element.addEventListener('wheel', handleWheel)
}
const handleScrollContainerEvent = (activeSlide: ActiveSlide) => {
const hasVerticalScrollbar = activeSlide.scrollHeight > activeSlide.clientHeight
if (!hasVerticalScrollbar) {
return
}
// 进入时重置滚动条
activeSlide.scrollTo(0, 0)
activeSlide.addEventListener('scroll', handleScroll)
handleContainerWheel(activeSlide)
}
// useScrollView(containerRef, (status: boolean) => {
// console.log(status, 'model出现')
// setInternalToggle(status?'on':'off')
// })
useEffect(() => {
console.log(prevClassname, nextClassname, 'prev&next')
}, [])
return bannerImgs.length
? <>
<Swiper
preventClicks={true}
autoHeight={autoHeight}
grabCursor={grabCursor}
updateOnWindowResize={true}
speed={speed}
loopPreventsSlide={bannerImgs.length > 1 ? loop : false}
uniqueNavElements={true}
// navigation={{
// nextEl: `.${nextClassname}`,
// prevEl: `.${prevClassname}`
// }}
// loopedSlides={3}
autoplay={autoplay}
nested={nested}
effect={effect}
lazy={{
loadPrevNext: true,
loadOnTransitionStart: true
}}
spaceBetween={spaceBetween}
slidesPerView={slidesPerView}
mousewheel={mousewheel}
direction={direction}
onSwiper={(swiper: any) => {
init(swiper)
}}
className={`${styles.swiper}
${haveNestedScrollbar ? styles.nestedScrollbar : ''}
${state?.clientType === CLIENT_TYPE.PC ? (noSwiping ? 'swiper-no-swiping' : '') : ''}
${className || ''}`}
centeredSlides={centeredSlides}
loop={bannerImgs.length > 1 ? loop : false}
onSlideChange={(slide) => {
// console.log(slide, 'CHANGE')
swiperRef.current = slide
if (haveNestedScrollbar) {
handleScrollContainerEvent(slide.slides[slide.realIndex] as ActiveSlide)
}
if (onBannerChange) {
onBannerChange(slide.realIndex)
}
}}
// onActiveIndexChange={(slide) => {
// // console.log(slide.realIndex, 'onbanngerChange')
// swiperRef.current = slide
// }}
onScroll={
(swiper, event) => {
onScrollDirection(event.deltaY > 0 ? 1 : -1)
}
}
onSlideChangeTransitionEnd={(slide) => {
onSlideChangeTransitionEnd(slide.realIndex)
}}
>
{bannerImgs?.map((item, index: number) => {
return (
<SwiperSlide key={index}>
{item}
{animations[index] && animations[index]}
</SwiperSlide>
)
})}
{shouldPaginationAnimate
? <animated.div
style={{
opacity: paginationController.to({
range: [0, 1],
output: [0, 1]
}),
translateY: paginationController.to({
range: [0, 1],
output: [140, 0]
})
}}
className={`${paginationClassname || ''}`}>
{pagination}
</animated.div>
: pagination
}
{navigation?.prev &&
<animated.div
style={{
opacity: leftController.to(animationToggle === 'on' ? {
range: [0, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 1],
output: [0, 0, 0, 0, 0, 0, 0, 1]
} : {
range: [1.0],
output: [1, 0]
}),
translateX: leftController.to(animationToggle === 'on' ? {
range: [0, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 1],
output: [40, 40, 40, 40, 40, 40, 40, 0]
} : {
range: [1, 0],
output: [0, 40]
}),
zIndex: 100,
position: 'absolute',
left: navigation.prevLeft || 0,
cursor: 'pointer',
top: navigation.prevTop || 0
// bottom: 0
}}
className={prevClassname || ''}
onClick={() => swiperRef.current && swiperRef.current.slidePrev()}
>
{navigation?.prev}
</animated.div>}
{navigation?.next &&
<animated.div
style={{
opacity: rightController.to({
range: [0, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 1],
output: [0, 0, 0, 0, 0, 0, 0, 1]
}),
translateX: rightController.to({
range: [0, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 1],
output: [-40, -40, -40, -40, -40, -40, -40, 0]
}),
zIndex: 100,
position: 'absolute',
right: navigation.nextRight || 0,
cursor: 'pointer',
top: navigation.nextTop || 0
// bottom: 0
}}
className={nextClassname || ''}
onClick={() => swiperRef.current && swiperRef.current.slideNext()}
>
{navigation?.next}
</animated.div>}
</Swiper >
{havePagination && <div className={`swiper-pagination ${paginationClassname || ''}`}>
{(bannerImgs.length > 1) ? bannerImgs?.map((item, index: number) => {
return (
<div className={'pageItemContainer'} onClick={() => { if (changeCurrent) changeCurrent(index) }} >
<span key={`pagination${index}`} className={classNames('paginationItem', current === index ? 'paginationItemActive' : '')} />
</div>
)
}) : null}
</div>}
</>
: null
}
export default Slider
less文件代码
.swiper {
font-size: 12px;
height: 100vh;
}
.nestedScrollbar {
&:global {
&>.swiper-wrapper>.swiper-slide {
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
}
}
}
:global {
.swiper-pagination {
position: absolute;
bottom: 1.5vw;
width: 100%;
text-align: center;
justify-content: center;
align-items: center;
display: flex;
z-index: 100;
.pageItemContainer {
height: 44px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
}
.paginationItem {
width: 40px !important;
height: 3px !important;
opacity: 0.2 !important;
background: #ffffff !important;
border-radius: 0px !important;
display: inline-block;
margin: 0px 4px;
transform: skewX(-45deg);
}
.paginationItemActive {
opacity: 1 !important;
}
.react-parallax {
height: 100vh;
width: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
@media screen and (max-width: 960px) {
:global {
.paginationItem {
width: 12.93vw !important;
height: 0.4vw !important;
opacity: 0.2 !important;
background: #ffffff !important;
border-radius: 0px !important;
display: inline-block;
margin: 0px 0.8vw;
transform: skewX(-45deg);
}
.paginationItemActive {
opacity: 1 !important;
}
}
}
三、swiper相关疑难点及处理
1、自定义切页控件
常规的切页如官网所示
实现思路: 将实例化的swiper对象用ref存储,回调传递给上级组件,上级组件通过相关api进行调用实现上下页切换以及通过下标直接切换至对应组件的效果。 样式上只要能有swiper的实例,完全自定义样式组件以及触发时机就行。
接下来上点强度,如何不使用固定的切页控件,而是将鼠标直接变成切页的控件呢,效果如下图。
先考虑鼠标控件的特征,因为会修改鼠标的样式,而鼠标状态是全局的,那么要如何做到什么时候改变鼠标、什么时候恢复鼠标就是必须要考虑的事情了,至于如何改变鼠标样式,十分简单,通过样式cursor:none将原来的鼠标直接隐藏, onMouseMove获取鼠标坐标然后绑定在某个节点的样式上,该节点就可以随鼠标移动而移动了。
那么我们怎么样才能让鼠标在可控区域内实现自定义,区域外取消自定义呢,我们定义一个自定义鼠标的组件,组件包裹我们想要组件生效的节点,通过props.children在组件内渲染节点,同时定义显示和隐藏的状态以供实时控制显隐,这样就能实现我们想要的所有控制啦。相关组件代码如下
import React, { ReactNode, MouseEvent, useRef, useState, useContext, useEffect } from 'react'
import styles from './index.module.less'
import { useTranslation } from 'react-i18next'
import XIcon from '../xIcon'
import { CLIENT_TYPE, DEVICE_TYPE } from '@/constants'
import classNames from 'classnames'
import { Common } from '@/../typings/common'
import { STORE_CONTEXT } from '_build/create-context'
import { IContext } from 'ssr-types-react'
interface mouseContainerProps {
cursorLeft?: ReactNode
cursorRight?: ReactNode
className?: string
visible?: boolean
onVisibleChange?: (status: boolean) => any
onPrev?: () => any
onNext?: () => any
length?: number
}
const MouseContainer: React.FC<mouseContainerProps> = (props) => {
const { t } = useTranslation()
const { state } = useContext<IContext<Common>>(STORE_CONTEXT)
const {
cursorLeft =
<div className={styles.cursorBtn}>
<XIcon name='arrow' />
<span>{t('app.prev')}</span>
</div>,
cursorRight =
<div className={styles.cursorBtn}>
<span>{t('app.next')}</span>
<XIcon name='left-arrow' />
</div>,
className,
length = 2
} = props
const cursor = useRef<HTMLDivElement>(null)
const [arrowLeft, setArrowLeft] = useState<boolean>(false)
const [cursorShow, setCursorShow] = useState<boolean>(false)
useEffect(() => {
console.info('visible:', props.visible)
setCursorShow(!!props.visible)
}, [props.visible])
const onMouseMove = (event: MouseEvent) => {
if (!cursor.current) {
return
}
setCursorShow(true)
props.onVisibleChange && props.onVisibleChange(true)
const { clientX: mouseX, clientY: mouseY } = event
cursor.current.style.left = mouseX - cursor.current.clientWidth / 2 + 'px'
cursor.current.style.top = mouseY - cursor.current.clientHeight / 2 + 'px'
setArrowLeft(mouseX < window.innerWidth / 2)
}
const onMouseLeave = () => {
setCursorShow(false)
props.onVisibleChange && props.onVisibleChange(false)
}
const onClick = () => {
console.info('arrowLeft:', arrowLeft)
console.info(props)
if (cursorShow) {
arrowLeft ? props.onPrev && props.onPrev() : props.onNext && props.onNext()
}
}
const onMouseDown = () => {
if (cursor.current) {
cursor.current.style.transform = 'scale(0.6)'
}
}
const onMouseUp = () => {
if (cursor.current) {
cursor.current.style.transform = 'scale(1)'
}
}
return <div
className={`${styles.mouseContainer} ${className || ''}`}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
style={length > 1 ? {} : { cursor: 'auto' }}
>
{props.children}
{(state?.clientType === CLIENT_TYPE.PC && length > 1)
? <div
style={state.deviceType === DEVICE_TYPE.PC ? {} : { visibility: 'hidden' }}
className={classNames(styles.mouseCursor, 'mouse-cursor', cursorShow ? styles.show : '')}
ref={cursor}
>
{
arrowLeft
? cursorLeft
: cursorRight
}
</div>
: null
}
</div>
}
export default MouseContainer
less文件
.mouseContainer {
position: relative;
overflow: hidden;
cursor: none;
.mouseCursor {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
width: 64Px;
height: 64Px;
border-radius: 50%;
background-color: transparent;
mix-blend-mode: difference;
border: 1px solid #ffffff;
z-index: 1000;
pointer-events: none;
color: #ffffff;
visibility: hidden;
transition: transform 0.3s ease-in-out;
user-select: none;
&.show {
visibility: visible;
}
.cursorContent {
margin-left: 3Px;
display: flex;
align-items: center;
justify-content: center;
}
}
:global {
.isMoving {
transform: scale(0.8);
}
.isClick {
transform: scale(0.5);
}
}
.mouseClick {}
.cursorBtn {}
}
@media screen and (min-width: 1440px) {
.mouseContainer {
.mouseCursor {
width: 80Px;
height: 80Px;
}
}
}
@media screen and (max-width: 960px) {
.mouseContainer {
.mouseCursor {
display: none;
}
}
}
2、无限循环loop下的动画状态控制
关于loop时swiper内的动画控制,因为loop的原理是在轮播时提前复制元素来实现的,这就意味着复制的知识某个状态下的元素,如果改元素有一些动态的变化,是不会自动同步的,举个例子,如果我设置当前页在视口的时候执行一次动画展示内容,那么当swiper自动复制时,只会复制某个状态下的元素,而这个复制动作一般是提前的,所以会导致对应到该元素展示的时候,动画直接失效了。这个问题处理办法有两个:
1、使用别的二次封装的插件
2、操作dom达到想要的控制效果
两种办法都不是很完美的处理方式,所以尽量还是别在swiper loop中给每个元素设置太多的动画状态,如下两张图是循环和不循环的对比
不循环:
3、swiper切页效果自定义
swiper支持几种常见的切换效果,简单的传递参数就能实现,但要实现一些自定义的效果,就得通过覆盖swiper默认样式来实现了,例如以下效果
如上效果就是基于前面基础的swiper组件自定义覆盖样式实现的,其相关核心样式代码如下
:global {
.swiper-wrapper {
display: flex;
align-items: center;
}
.swiper-slide-active,
.swiper-slide-duplicate-active {
z-index: 10;
transform: scale(1) !important;
&:after {
opacity: 0 !important;
}
}
.swiper-slide {
position: relative;
transform: scale(0.87);
will-change: transform;
width: 980px !important;
transition: transform 1s ease-in-out;
&:after {
content: " ";
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
opacity: 0.4;
background: #000;
transition: opacity 1s ease-in-out;
will-change: opacity;
}
}
}
四、以swiper为基础定制化扩展动画效果
观察上面完整的swiper组件代码,我们可以知道,每个轮播元素可以是一个ReactNode,靠这种特性,我们可以自定义各种扩展效果,比如上面车型轮播,就是通过current参数的匹配每次触发当前页的动画,同时通过@mladenilic/threesixty.js实现仿3D效果,react-draggable实现控件拖拽。代码如下 3D效果组件:
import React, { useEffect, useRef, useState } from 'react'
import ThreeSixty from '@mladenilic/threesixty.js'
import styles from './index.module.less'
import MovableBtn from '@/components/movableBtn'
import XTracker from '../xTracker'
// const demoData = [
// { Num: 0, Url: 'https://panovr.autoimg.cn/pano/g28/M06/2D/CE/ChsEnl7E0UWAChgOAACojyFCqes376.png', info: null },
// { Num: 1, Url: 'https://panovr.autoimg.cn/pano/g27/M05/7B/DA/ChwFkV7E0UWAe3YtAACbwFKEGw8935.png', info: null },
// { Num: 2, Url: 'https://panovr.autoimg.cn/pano/g28/M0A/2D/FE/ChsEfV7E0UWAIy9SAACA9wTsu5Y538.png', info: null },
// { Num: 3, Url: 'https://panovr.autoimg.cn/pano/g28/M04/72/D5/ChwFkl7E0UWAD6XUAABse_--Qv8415.png', info: null },
// { Num: 4, Url: 'https://panovr.autoimg.cn/pano/g28/M07/72/D5/ChwFkl7E0UWAMiUjAACCboaB1x8381.png', info: null },
// { Num: 5, Url: 'https://panovr.autoimg.cn/pano/g27/M04/2D/77/ChsEnV7E0UWAZVbGAACXMcLYpKQ822.png', info: null },
// { Num: 6, Url: 'https://panovr.autoimg.cn/pano/g28/M0B/72/D5/ChwFkl7E0UWAdhuMAACmBCKa4uc090.png', info: null },
// { Num: 7, Url: 'https://panovr.autoimg.cn/pano/g27/M0A/2D/77/ChsEnV7E0UWAbWArAAC19S0OuAs074.png', info: null },
// { Num: 8, Url: 'https://panovr.autoimg.cn/pano/g24/M07/30/4C/ChwFjl7E0UWAdlKjAAC-qyBZ9Wc829.png', info: null },
// { Num: 9, Url: 'https://panovr.autoimg.cn/pano/g28/M06/2D/FE/ChsEfV7E0UWAC6hWAADAtDVi6Nw583.png', info: null },
// { Num: 10, Url: 'https://panovr.autoimg.cn/pano/g27/M06/2D/77/ChsEnV7E0UaAXueoAADEl1OMFsg565.png', info: null },
// { Num: 11, Url: 'https://panovr.autoimg.cn/pano/g24/M06/30/4D/ChwFjl7E0UaAczmCAADA13-YRbw065.png', info: null },
// { Num: 12, Url: 'https://panovr.autoimg.cn/pano/g24/M05/30/4D/ChwFjl7E0UaAETj8AADB3JYbIzI382.png', info: null },
// { Num: 13, Url: 'https://panovr.autoimg.cn/pano/g24/M06/30/4D/ChwFjl7E0UaANjaeAADCWg8XUOo126.png', info: null },
// { Num: 14, Url: 'https://panovr.autoimg.cn/pano/g27/M05/2D/78/ChsEnV7E0UaAOPtJAAC0OMky4UQ373.png', info: null },
// { Num: 15, Url: 'https://panovr.autoimg.cn/pano/g24/M09/30/4D/ChwFjl7E0UaAW-t9AACkivDm1ME994.png', info: null },
// { Num: 16, Url: 'https://panovr.autoimg.cn/pano/g28/M0A/2D/CE/ChsEnl7E0UaARXyEAACT_CjNLdc199.png', info: null },
// { Num: 17, Url: 'https://panovr.autoimg.cn/pano/g28/M00/2D/CE/ChsEnl7E0UaAHYyLAAB_cNzNJeo696.png', info: null },
// { Num: 18, Url: 'https://panovr.autoimg.cn/pano/g24/M02/30/4E/ChwFjl7E0UaAHIjyAABlACpG93k914.png', info: null },
// { Num: 19, Url: 'https://panovr.autoimg.cn/pano/g24/M03/30/4E/ChwFjl7E0UaAWY8TAAB-F3EUtlc848.png', info: null },
// { Num: 20, Url: 'https://panovr.autoimg.cn/pano/g28/M0A/2D/CF/ChsEnl7E0UaAMxzeAACTnhmCo0Y067.png', info: null },
// { Num: 21, Url: 'https://panovr.autoimg.cn/pano/g28/M00/72/D5/ChwFkl7E0UaAEW86AACl4eEgTRM443.png', info: null },
// { Num: 22, Url: 'https://panovr.autoimg.cn/pano/g28/M03/2D/CF/ChsEnl7E0UaAc24HAAC1JYATIp4680.png', info: null },
// { Num: 23, Url: 'https://panovr.autoimg.cn/pano/g28/M05/72/D5/ChwFkl7E0UaADipDAAC_2giMWGc579.png', info: null },
// { Num: 24, Url: 'https://panovr.autoimg.cn/pano/g24/M0A/30/4F/ChwFjl7E0UeAWbNWAADPZtBfxtw088.png', info: null },
// { Num: 25, Url: 'https://panovr.autoimg.cn/pano/g24/M0A/30/4F/ChwFjl7E0UeAMcDKAADAXy8D2TQ229.png', info: null },
// { Num: 26, Url: 'https://panovr.autoimg.cn/pano/g28/M04/72/D6/ChwFkl7E0UeALeO8AADFjIKvksE938.png', info: null },
// { Num: 27, Url: 'https://panovr.autoimg.cn/pano/g28/M08/72/D6/ChwFkl7E0UeAE98dAADB9mCvQ9E453.png', info: null },
// { Num: 28, Url: 'https://panovr.autoimg.cn/pano/g28/M05/2D/CF/ChsEnl7E0UiABmtlAADFDUylX34671.png', info: null },
// { Num: 29, Url: 'https://panovr.autoimg.cn/pano/g28/M06/2D/CF/ChsEnl7E0UiANB6QAAC5X_WwDBk125.png', info: null }
// ]
interface FullViewProsRule {
shouldShowExtra: boolean
carImg: string
carModelName?: string
}
const FullView = (props: FullViewProsRule) => {
const { shouldShowExtra, carImg, carModelName } = props
const imgRef = useRef<HTMLDivElement>(null)
const prevRef = useRef<HTMLDivElement>(null)
const nextRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const threesixty = useRef<any>(null)
const baseRef = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState<number>()
const [height, setHeight] = useState<number>()
const [dispX, setDispX] = useState<number>(0)
const [currentDirection, setCurrentDirection] = useState<number>(0)
const windowWidth = useRef<number>(0)
const initCar = () => {
if (threesixty.current) {
threesixty?.current?.destroy()
}
if (imgRef.current) {
// eslint-disable-next-line no-new
threesixty.current = new ThreeSixty(imgRef.current, {
image: carImg,
width: width,
height: height,
current: currentDirection,
count: 36,
perRow: 4,
interactive: true,
draggable: false,
swipeable: false,
prev: prevRef.current,
next: nextRef.current
})
}
}
useEffect(() => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth)
setHeight(containerRef.current.clientHeight)
}
const resizeObserver = new ResizeObserver((entries) => {
// 监听屏幕宽度变化
entries.forEach((entry) => {
// console.log(entry, 'screen&&last')
// windowWidth.current > 0 && location.reload()
// windowWidth.current = entry.contentRect.width
if (containerRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
if (containerRef.current) {
setWidth(entry.contentRect.width)
setHeight(entry.contentRect.height)
}
})
})
resizeObserver.observe(containerRef.current)
}
})
})
resizeObserver.observe(document.body)
}, [])
useEffect(() => {
initCar()
}, [width, height])
// useEffect(() => {
// !shouldShowExtra && threesixty.current && threesixty.current.goto(0)
// }, [shouldShowExtra])
useEffect(() => {
changeDirection(dispX)
}, [dispX])
useEffect(() => {
threesixty.current && threesixty.current.goto(currentDirection)
}, [currentDirection])
const changeDirection = (x: number) => {
console.log(Math.ceil(x / 36), 'Math.ceil(x / 36)')
if (baseRef.current) {
const singel = baseRef.current.offsetWidth / 36
const dir = Math.floor(x / singel)
setCurrentDirection(dir)
}
}
return <div ref={containerRef} className={styles.fullView}>
<div ref={imgRef} >
</div>
<div
className={styles.fullviewBase}
ref={baseRef}
style={{
opacity: shouldShowExtra ? 1 : 0,
transition: 'all 1s ease-in-out'
}}
>
<img
src='https://xp-ams.s3.eu-central-1.amazonaws.com/www/public/static/img/base@2x.bd7852eb.png'
/>
<div style={{
width: '80%',
height: '80%',
position: 'absolute',
bottom: '0',
display: 'flex',
justifyContent: 'center'
}}>
<MovableBtn
changeDispX={(num: number) => {
setDispX(num)
}}
onMouseDown = {() => {
carModelName && XTracker.trackHandler({ event: 'Model_360viewBtn', type: 'Home', params: { model_page: carModelName } })
}}
width={baseRef.current ? baseRef.current?.clientWidth / 2 : undefined}
height={baseRef.current ? baseRef.current?.clientHeight : undefined}
/>
</div>
</div>
</div>
}
export default FullView
3D效果less
.fullView {
position: relative;
// background: url('https://xp-ams.s3.eu-central-1.amazonaws.com/www/public/static/img/base@2x.bd7852eb.png');
width: 46.88vw;
height: 26.38vw;
user-select: none;
.threesixtyImages {
list-style: none;
margin: 0;
padding: 0;
:global {
img {
position: absolute;
top: 0;
width: 100%;
height: auto;
}
}
.previousImage {
visibility: hidden
}
}
}
.fullviewBase {
width: 46.88vw;
position: absolute;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
user-select: none;
:global {
img {
width: 100%;
object-fit: contain;
user-select: none;
}
}
}
.pageBtn {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #000000;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
bottom: 105px;
left: 50%;
transform: translate3d(-50%, 0, 0);
cursor: pointer;
.btn1 {
display: flex;
align-items: center;
justify-content: center;
margin-right: -10%;
}
.btn2 {
display: flex;
align-items: center;
justify-content: center;
margin-left: -10%;
}
}
@media screen and(max-width: 960px) {
.fullView {
position: relative;
// background: url('https://xp-ams.s3.eu-central-1.amazonaws.com/www/public/static/img/base@2x.bd7852eb.png') no-repeat ;
width: 89.33vw;
height: 50.38vw;
}
.fullviewBase {
width: 100%;
}
}
拖拽控件:
import React, { useEffect, useRef } from 'react'
import styles from './index.module.less'
import XIcon from '../xIcon'
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'
interface MovableBtnProps {
// boundParent: React.RefObject<HTMLDivElement>
changeDispX: (num: number) => void
width?: number
height?: number
onMouseDown?: () => void
}
const MovableBtn = (props: MovableBtnProps) => {
const { changeDispX, width = 450, height = 88, onMouseDown } = props
const movableRef = useRef<HTMLDivElement>(null)
const handelDrag = (
e: DraggableEvent | null,
data: DraggableData | { x: number }
) => {
e && e.stopPropagation()
console.log(width, height, 'eeeeee')
console.log(data, 'dragable Data')
changeDispX(data.x)
if (movableRef.current) {
console.log(data.x, width, height, 'x')
movableRef.current.style.bottom = `${height - Math.sqrt((width * width - data.x * data.x) / (width * width) * height * height) - movableRef.current.clientHeight / 2}px`
}
}
useEffect(() => {
console.log('初始化按钮位置')
handelDrag(null, { x: 0 })
}, [])
return (
<Draggable
// handle={styles.pageBtn}
axis='x'
onDrag={handelDrag}
bounds='parent'
onMouseDown={onMouseDown && onMouseDown}
>
<div
className={styles.pageBtn}
ref={movableRef}
>
<div className={styles.btn1}>
<img draggable="false" src='https://xp-ams.s3.eu-central-1.amazonaws.com/www/public/static/img/model-changer.0cc38731.png' />
</div>
</div>
</Draggable>
)
}
export default MovableBtn
拖拽控件less:
.pageBtn {
width: 2.08vw;
height: 2.08vw;
border-radius: 50%;
background-color: #000000;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
bottom: -1.04vw;
cursor: pointer;
.btn1 {
display: flex;
align-items: center;
justify-content: center;
// margin-right: -10%;
user-select: all;
}
.btn2 {
display: flex;
align-items: center;
justify-content: center;
// margin-left: -10%;
}
}
@media screen and (max-width: 960px) {
.pageBtn {
width: 9.6vw;
height: 9.6vw;
.btn1 {
:global {
.icon {
font-size: 5.07vw !important;
margin-right: -1.5vw;
}
}
}
.btn2 {
:global {
.icon {
font-size: 5.07vw !important;
margin-left: -1.5vw;
}
}
}
}
}
下面再列举几个别的效果:
1、照片墙展示
思路:通过react-spring的useSprings完成入场效果,swiper组件完成切页轮播的控制,MouseContainer完成鼠标样式的更改,react-use-gesture和react-spring的useSpring完成单张图片效果的控制
这里react-spring和轮播都是基本用法,单张图片的效果实现可能比较新颖,下面列一下单张图片效果实现的核心代码:
import React, { ReactNode, useEffect, useRef } from 'react'
import { useSpring, animated } from '@react-spring/web'
import styles from './index.module.less'
import { useGesture } from 'react-use-gesture'
interface TitleAnimationPropsRule {
toggle?: string
image?: ReactNode
}
const calcX = (y: number, ly: number) => -(y - ly - window.innerHeight / 2) / 100
const calcY = (x: number, lx: number) => (x - lx - window.innerWidth / 2) / 100
export default function PhotoWallAnimation (props: TitleAnimationPropsRule) {
const { toggle, image } = props
useEffect(() => {
const preventDefault = (e: Event) => e.preventDefault()
document.addEventListener('gesturestart', preventDefault)
document.addEventListener('gesturechange', preventDefault)
return () => {
document.removeEventListener('gesturestart', preventDefault)
document.removeEventListener('gesturechange', preventDefault)
}
}, [])
const domTarget = useRef(null)
const [{ x, y, rotateX, rotateY, rotateZ }, api] = useSpring(
() => ({
rotateX: 0,
rotateY: 0,
rotateZ: 0,
zoom: 0,
opacity: 0.4,
x: 0,
y: 0,
config: { duration: 100, mass: 5, tension: 350, friction: 40 }
})
)
useGesture(
{
onPinch: ({ offset: [d, a] }) => api({ zoom: toggle === 'on' ? (d / 200) : 0, rotateZ: toggle === 'on' ? a : 0 }),
onMove: ({ xy: [px, py], dragging }) => {
!dragging &&
api({
rotateX: calcX(py, y.get()),
rotateY: calcY(px, x.get())
})
},
onHover: ({ hovering }) =>
!hovering && api({ rotateX: 0, rotateY: 0 })
// onWheel: ({ event, offset: [, y] }) => {
// event.preventDefault()
// wheelApi.set({ wheelY: y })
// },
},
{ domTarget, eventOptions: { passive: false } }
)
return (
<animated.div
ref={domTarget}
className={styles.card}
style={{
transform: 'perspective(1000px)',
transformOrigin: '50% 50%',
x,
y,
rotateX,
rotateY,
rotateZ,
opacity: 1
}}>
<div
className={styles.cursorArea}
>
{image}
</div>
</animated.div>
)
}
2、扩展自定义样式实现更复杂的效果
3、配合lottie实现更高级的动画效果
4、项目全局使用的产品介绍轮播页
这些效果都依赖于swiper实现,受篇幅所限,这里就不一一细讲每种效果的实现方式来,后面单独写一篇出来汇总一下哪些边边角角的效果实现插件的使用。