React 不同高度的轮播动效其实没有那么难

163 阅读3分钟

前言

正如去哪儿旅游APP页面显示,每个SwiperItem的高度不同,在滑动的过程中呈现高度缩放的动画

02ac9036de017a7347f6390ebfac4e56.gif

实现代码之前先吐槽两句:这明明可以凑成两个轮播页,这样就不需要这样的动画了,何必拆成三个呢(虽然我知道是为了分类)

image.png

实现

布局及宽度问题

其实不管是通过上面的视频还是其他的Swiper代码,我们都知道所有的swiper-item都是横向排列的并且处于同一个父容器当中,切换swiper-item的时候,每一个swiper-item的宽度就是可视区的宽度,我们只需要挪动外层的父容器即可

image.png

转化为代码应为以下的结构:

<!-- 可视区 -->
<div class="container">
  <!-- 轮播区 -->
  <div class="swiper-content">
    <div class="swiper-item" style="height: 20px"></div>
    <div class="swiper-item" style="height: 40px"></div>
    <div class="swiper-item" style="height: 80px"></div>
  </div>
</div>

如何让可视区每次只展示一个swiper-item,当然是使用overflow:hidden来控制啦

当我们使用flex布局,将所有swiper-item位于同一行时,我们发现所有的swiper-item都挤在可视区内,因此我们也需要获取到可视区的宽度,那么swiper的整体宽度就可以计算了

简略代码

const HSwiper = ({children}) => {
  let length = Children.count(children)
  // 获取节点
  const contentRef = useRef()
  // 可视区的宽度
  const [containerWidth, setContainerWidth] = useState()
  useEffect(() => {
    const reg = /[0-9]+/g
    const containerNode = contentRef.current
    let cWidth = Number(getComputedStyle(containerNode).getPropertyValue("width").match(reg)?.[0])
    setContainerWidth(cWidth)
  }, [])
  
  return (
    < !--可视区 -- >
    <div class="container">
      <!-- 轮播区 -->
      <div class="swiper-content" ref={contentRef} style={{ width: `${containerWidth * length}px`}}>
        <div class="swiper-item" style="height: 20px"></div>
        <div class="swiper-item" style="height: 40px"></div>
        <div class="swiper-item" style="height: 80px"></div>
      </div>
    </div>
    )
}

高度问题

众所周知,一个高度未被设置的块元素,其实际高度为子元素撑开的高度。

如以下代码,swiper这个元素的高度取决于height最高的swiper-item

image.png 但是我们想要的是swiper这个容器的高度随着切换到不同的swiper-item时,适应其高度(既不留空白,也不遮挡展示)

解决方法:CSS难以实现(如果可以实现,请大佬教教我🫡),我使用的方法是获取并存储每个swiper-item的高度,在切换的时候动态修改即可

useEffect(() => {
  const temp: Array<number> = []
  const reg = /[0-9]+/g
  const containerNode = contentRef.current
  let timer = setTimeout(() => {
    for (let item of contentRef.current.children) {
      let itemHeight = Number(getComputedStyle(item).getPropertyValue("height").match(reg)?.[0])
      temp.push(itemHeight)
      setHeightList(temp)
    }
  }, 200)
  return () => {
    timer && clearTimeout(timer)
  }
}, [])

大家可能会好奇,获取每一个swiper-item节点为什么要用setTimeout处理,这是因为父组件已经挂载完成,但是children却没有,所以我们没办法同步中获取到children节点,因此我选择了延迟处理,当然还有其他方法,比如回调函数等

最后不要忘记在组件卸载的时候,清除定时器,减少特殊情况下产生的性能损耗

滑动问题

要求

  1. 滑动的时候高度就发生改变,而不是滑动到下一个swiper-item后高度改变
  2. 滑动移动一定距离时才滑动到下一个swiper-item,不然会返回原先的swiper-item

注意: 关于滑动的事件onTouchStartonTouchMoveonTouchEnd是移动端的事件,在浏览器中调试必须设置为移动端才能触发事件

// 当前展示swiper-item的下标
const [currentIndex, setCurrentIndex] = useState(startIndex)
// 滑动开始的位置
const [moveStart, setMoveStart] = useState(0)
// 滑动的距离
const [slideLength, setSlideLength] = useState(0)
// 滑动时高度的偏差
const [moveOffset, setMoveOffset] = useState(0)

<div className={'swiper-content'}
  ref={contentRef}
  style={{
    width: `${containerWidth * length}px`,
    height: `${(heightList[currentIndex]) + moveOffset}px`,
    transform: `translateX(${-containerWidth * currentIndex + slideLength}px)`
  }}
  onTouchStart={(e) => {
    setMoveStart(e.touches[0].clientX)
  }}
  onTouchMove={(e) => {
    let length = e.touches[0].clientX - moveStart
    // 判断滑动方向
    const flag = length > 0 ? -1 : 1
    const currentHeight = heightList[currentIndex]
    const tagetHeight = heightList[currentIndex + flag]
    // 判断高度差
    if (currentHeight > tagetHeight) {
      setMoveOffset(-Math.abs(length))
    }
    if (currentHeight < tagetHeight) {
      setMoveOffset(Math.abs(length))
    }
    setSlideLength(length)
  }}
  onTouchEnd={(e) => {
    if (Math.abs(slideLength) > 50) {
      if (slideLength < 0 && currentIndex !== length - 1) setCurrentIndex(pre => pre + 1);
      if (slideLength > 0 && currentIndex !== 0) setCurrentIndex(pre => pre - 1)
    }
    setSlideLength(0)
    setMoveStart(0)
    setMoveOffset(0)

  }}
>
  {
    React.Children.map(children, (child, index) => (
      <div className='page'>
        {child}
      </div>
    ))
  }
</div>

完整代码

类型声明

export interface SwiperType {
  children: React.ReactElement
  startIndex?: number,
  className?: string;
  style?: CSSProperties;
  isNeedCalculate?: boolean // 是否需要计算高度
}

组件代码

const HSwiper = (props: SwiperType) => {
  let {
    children,
    startIndex = 0,
    className,
    style,
    isNeedCalculate = false
  } = props

  let length = Children.count(children)
  const [currentIndex, setCurrentIndex] = useState(startIndex)
  const [heightList, setHeightList] = useState([])
  const [containerWidth, setContainerWidth] = useState()
  const [moveStart, setMoveStart] = useState(0)
  const [slideLength, setSlideLength] = useState(0)
  const [moveOffset, setMoveOffset] = useState(0)
  const contentRef = useRef()

  useEffect(() => {
    let timer: null | NodeJS.Timeout = null
    if (isNeedCalculate) {
      const temp: Array<number> = []
      const reg = /[0-9]+/g
      const containerNode = contentRef.current
      let cWidth = Number(getComputedStyle(containerNode).getPropertyValue("width").match(reg)?.[0])
      setContainerWidth(cWidth)
      console.log(contentRef.current, children)
      timer = setTimeout(() => {
        for (let item of contentRef.current.children) {
          let itemHeight = Number(getComputedStyle(item).getPropertyValue("height").match(reg)?.[0])
          temp.push(itemHeight)
          setHeightList(temp)
        }
      }, 200)
    }
    return () => {
      timer && clearTimeout(timer)
    }
  }, [])

  return (
    <div className={`container ${className}`} style={{ ...style }} >
      <div className={'swiper-content'}
        ref={contentRef}
        style={{
          width: `${containerWidth * length}px`,
          height: `${(heightList[currentIndex]) + moveOffset}px`,
          transform: `translateX(${-containerWidth * currentIndex + slideLength}px)`
        }}
        onTouchStart={(e) => {
          setMoveStart(e.touches[0].clientX)
        }}
        onTouchMove={(e) => {
          let length = e.touches[0].clientX - moveStart
          // 判断滑动方向
          const flag = length > 0 ? -1 : 1
          const currentHeight = heightList[currentIndex]
          const tagetHeight = heightList[currentIndex + flag]
          // console.log(currentHeight,tagetHeight,length)
          // 判断高度差
          if (currentHeight > tagetHeight) {
            setMoveOffset(-Math.abs(length))
          }
          if (currentHeight < tagetHeight) {
            setMoveOffset(Math.abs(length))
          }
          setSlideLength(length)

        }}
        
        onTouchEnd={(e) => {
          if (Math.abs(slideLength) > 50) {
            console.log(slideLength)
            if (slideLength < 0 && currentIndex !== length - 1) setCurrentIndex(pre => pre + 1);
            if (slideLength > 0 && currentIndex !== 0) setCurrentIndex(pre => pre - 1)
          }
          setSlideLength(0)
          setMoveStart(0)
          setMoveOffset(0)
        }}
      >
        {
          React.Children.map(children, (child, index) => (
            <div className='page'>
              {child}
            </div>
          ))
        }
      </div>
    </div>
  )

样式

.container {
  overflow: hidden;
}

.swiper-content {
  display: flex;
  transition: all 0.3s ease-out;
}

.page {
  width: 100%;
  height: 100%;
}

测试效果

20240714_193111.gif

const WrapItem = () => {
  return (
    <div className={"wrap-item"}></div>
  )
}

const Test: React.FC = () => {
  return (
    <div style={{ width: "100%" }}>
      <HSwiper isNeedCalculate={true}>
        <div>
          {
            new Array(4).fill(0).map(() => <WrapItem />)
          }
        </div>
        <div>
          {
            new Array(8).fill(0).map(() => <WrapItem />)
          }
        </div>
        <div>
          {
            new Array(4).fill(0).map(() => <WrapItem />)
          }
        </div>
      </HSwiper>
      <WrapItem />
    </div>
  )
}

RAF优化?·

在整个滑动中,滑动过程事件(onTouchMove)触发最为频繁,而且在事件中更新moveOffseslideLength两个状态,从性能考虑,我想着是不是可以使用requestAnimationFrame进行优化呢?

onTouchMove={(e) => {
  let length = e.touches[0].clientX - moveStart
  // 判断滑动方向
  const flag = length > 0 ? -1 : 1
  const currentHeight = heightList[currentIndex]
  const tagetHeight = heightList[currentIndex + flag]
  // 判断高度差
  requestAnimationFrame(() => {
    if (currentHeight > tagetHeight) {
      setMoveOffset(-Math.abs(length))
    }
    if (currentHeight < tagetHeight) {
      setMoveOffset(Math.abs(length))
    }
    setSlideLength(length)
  })
}}

20240714_222816.gif 貌似事情并不是我想的那么简单,整体的交互都出现了问题,非常明显是我的写法有问题😂,通过查找得知,应该是以下问题:

每次onTouchMove触发时,如果直接调用setState来更新状态,这可能会导致React频繁地重新渲染组件。虽然requestAnimationFrame用于控制动画的每一帧更新,但如果每一帧都触发setState,那么即使使用了RAF,也会因为React的重新渲染机制而导致性能问题。