C端官网项目动画(swiper封装以及相关定制化开发)

2,755 阅读5分钟

一、swiper简介

swiper是一款图片轮播插件,基本所有涉及图片轮播的相关功能都能通过swiper实现。

官网: www.swiper.com.cn/

市面上常见的轮播插件优点缺点
swiper覆盖了轮播常见的基本所有功能,也有相对较好的可扩展性,特别是在跨端上有着较好的兼容性,6以上版本支持ssr包体积大,冗杂,部分功能实现过于繁琐,api也很多,容易让人眼花缭乱
slick一款很精致的插件,功能调用api直观易懂,自定义可扩展也很丰富 demo网站:kenwheeler.github.io/slick/依赖jquery,可能不支持ssr
react-slickslick的react版本,最流行,易定制 demo网站:react-slick.neostack.com/docs/exampl…

二、相关常用参数说明及完整swiper组件

常用参数如下 image.png 完整组件代码如下

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、自定义切页控件

常规的切页如官网所示

Kapture 2023-02-28 at 19.52.37.gif 较为简陋,一般项目中都需要定制化程度更高的切页控件效果

实现思路: 将实例化的swiper对象用ref存储,回调传递给上级组件,上级组件通过相关api进行调用实现上下页切换以及通过下标直接切换至对应组件的效果。 样式上只要能有swiper的实例,完全自定义样式组件以及触发时机就行。

接下来上点强度,如何不使用固定的切页控件,而是将鼠标直接变成切页的控件呢,效果如下图。

Kapture 2023-02-28 at 20.09.36.gif

先考虑鼠标控件的特征,因为会修改鼠标的样式,而鼠标状态是全局的,那么要如何做到什么时候改变鼠标、什么时候恢复鼠标就是必须要考虑的事情了,至于如何改变鼠标样式,十分简单,通过样式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中给每个元素设置太多的动画状态,如下两张图是循环和不循环的对比

不循环:

Kapture 2023-03-09 at 20.07.56.gif 循环(状态不一致导致的闪动): Kapture 2023-03-09 at 20.13.54.gif

3、swiper切页效果自定义

swiper支持几种常见的切换效果,简单的传递参数就能实现,但要实现一些自定义的效果,就得通过覆盖swiper默认样式来实现了,例如以下效果

Kapture 2023-03-09 at 19.51.08.gif

如上效果就是基于前面基础的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、照片墙展示

Kapture 2023-03-09 at 20.42.06.gif

思路:通过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、扩展自定义样式实现更复杂的效果

Kapture 2023-03-09 at 21.06.48.gif

3、配合lottie实现更高级的动画效果

Kapture 2023-03-09 at 21.01.51.gif

4、项目全局使用的产品介绍轮播页

Kapture 2023-03-09 at 21.12.20.gif

这些效果都依赖于swiper实现,受篇幅所限,这里就不一一细讲每种效果的实现方式来,后面单独写一篇出来汇总一下哪些边边角角的效果实现插件的使用。