前言
正如去哪儿旅游APP页面显示,每个SwiperItem
的高度不同,在滑动的过程中呈现高度缩放的动画
实现代码之前先吐槽两句:这明明可以凑成两个轮播页,这样就不需要这样的动画了,何必拆成三个呢(虽然我知道是为了分类)
实现
布局及宽度问题
其实不管是通过上面的视频还是其他的Swiper代码,我们都知道所有的swiper-item
都是横向排列的并且处于同一个父容器当中,切换swiper-item
的时候,每一个swiper-item
的宽度就是可视区的宽度,我们只需要挪动外层的父容器即可
转化为代码应为以下的结构:
<!-- 可视区 -->
<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
但是我们想要的是
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
节点,因此我选择了延迟处理,当然还有其他方法,比如回调函数等
最后不要忘记在组件卸载的时候,清除定时器,减少特殊情况下产生的性能损耗
滑动问题
要求:
- 滑动的时候高度就发生改变,而不是滑动到下一个
swiper-item
后高度改变 - 滑动移动一定距离时才滑动到下一个
swiper-item
,不然会返回原先的swiper-item
注意:
关于滑动的事件onTouchStart
、onTouchMove
、onTouchEnd
是移动端的事件,在浏览器中调试必须设置为移动端才能触发事件
// 当前展示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%;
}
测试效果
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
)触发最为频繁,而且在事件中更新moveOffse
和slideLength
两个状态,从性能考虑,我想着是不是可以使用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)
})
}}
貌似事情并不是我想的那么简单,整体的交互都出现了问题,非常明显是我的写法有问题😂,通过查找得知,应该是以下问题:
每次
onTouchMove
触发时,如果直接调用setState
来更新状态,这可能会导致React频繁地重新渲染组件。虽然requestAnimationFrame
用于控制动画的每一帧更新,但如果每一帧都触发setState
,那么即使使用了RAF,也会因为React的重新渲染机制而导致性能问题。