React实现图片数组无限滚动:解决滑块切换跳转首尾的问题
实现平滑无限滚动的滑块(无第三方依赖)
当前代码在滑块滚动到边界时直接切换索引,导致出现跳转感。要实现无缝的无限滚动,核心思路是复制首尾滑块并在滚动完成后无缝重置索引,模拟无限循环的视觉效果,具体修改如下:
步骤1:修改滑块数据,添加首尾复制项
在组件内部,给原滑块数组的前后各添加一个复制项:
const extendedSliderItems = [ ...sliderItems.slice(-1), // 复制最后一个真实滑块 ...sliderItems, // 所有真实滑块 ...sliderItems.slice(0,1) // 复制第一个真实滑块 ];
步骤2:调整初始索引与点击逻辑
初始索引设为1(因为第一个是复制的最后一个滑块,默认显示第一个真实滑块);点击箭头时直接增减索引,无需判断边界:
const [slideIndex, setSlideIndex] = useState(1); const handleClick = (direction) => { if(direction === "left"){ setSlideIndex(prev => prev - 1); }else{ setSlideIndex(prev => prev + 1); } };
步骤3:监听滚动结束事件,无缝重置索引
使用useRef获取Wrapper元素,监听transitionend事件,当滚动到复制的滑块时,瞬间重置索引并临时关闭过渡动画,避免跳转感:
import React, { useState, useRef, useEffect } from 'react' // 新增useRef和useEffect // ... 其他导入不变 const Slider = () => { const [slideIndex, setSlideIndex] = useState(1); const wrapperRef = useRef(null); useEffect(() => { const handleTransitionEnd = () => { // 滚动到开头的复制滑块时,重置到真实的最后一个滑块索引 if(slideIndex === 0){ setSlideIndex(sliderItems.length); wrapperRef.current.style.transition = 'none'; // 强制重绘后恢复过渡动画 requestAnimationFrame(() => { wrapperRef.current.style.transition = 'transform 1.5s ease-in-out'; }); } // 滚动到结尾的复制滑块时,重置到真实的第一个滑块索引 if(slideIndex === sliderItems.length + 1){ setSlideIndex(1); wrapperRef.current.style.transition = 'none'; requestAnimationFrame(() => { wrapperRef.current.style.transition = 'transform 1.5s ease-in-out'; }); } }; const wrapper = wrapperRef.current; wrapper?.addEventListener('transitionend', handleTransitionEnd); return () => wrapper?.removeEventListener('transitionend', handleTransitionEnd); }, [slideIndex, sliderItems.length]); // ... handleClick逻辑不变
步骤4:更新JSX渲染部分
修改Wrapper的ref绑定,同时渲染扩展后的滑块数组(注意重复项的key要添加索引区分):
<Wrapper ref={wrapperRef} slideIndex={slideIndex}> {extendedSliderItems.map((item, idx) => ( <Slide key={`${item.id}-${idx}`}> <ImgContainer> <Image src={item.img} alt={item.alt}/> </ImgContainer> <InfoContainer> <Title>{item.title}</Title> <Desc>{item.desc}</Desc> <Button>Shop Now</Button> </InfoContainer> </Slide> ))} </Wrapper>
完整修改后的组件代码
import React, { useState, useRef, useEffect } from 'react' import styled from 'styled-components'; import ArrowLeftIcon from '@mui/icons-material/ArrowLeft'; import ArrowRightIcon from '@mui/icons-material/ArrowRight'; import { sliderItems } from '../data'; import { mobile } from '../responsive'; const Container = styled.div` width: 100%; height: 100vh; display: flex; position: relative; overflow: hidden; `; const Arrow = styled.div` width: 50px; height: 50px; background-color: #eee; border-radius: 50%; display: grid; place-items: center; position: absolute; top: 0; bottom: 0; left: ${props => props.direction === "left" && "10px"}; right: ${props => props.direction === "right" && "10px"}; margin: auto; cursor: pointer; opacity: 0.5; z-index: 2; ${mobile({width:"30px", height:"30px"})} `; const Wrapper = styled.div` height: 100%; display: flex; transform: translateX(${props =>props.slideIndex * -100}vw); transition: transform 1.5s ease-in-out; `; const Slide = styled.div` width: 100vw; height: 100%; display: flex; align-items: center; `; const ImgContainer = styled.div` height: 100%; flex: 1; `; const Image = styled.img` width: 100%; height: 100%; object-fit: cover; `; const InfoContainer = styled.div` margin-left: 50px; flex: 1; padding: 10px; ${mobile({ display: "none" })} `; const Title = styled.h1` font-size: 60px; `; const Desc = styled.p` margin: 50px 0px; font-size: 20px; font-weight: 500; `; const Button = styled.button` padding: 10px; font-size: 25px; background-color: transparent; cursor: pointer; border-radius: 4px; transition: all 0.25s ease; &:hover { transform: scale(1.05); color: white; background-color: black; } `; const Slider = () => { const [slideIndex, setSlideIndex] = useState(1); const wrapperRef = useRef(null); // 扩展滑块数组,添加首尾复制项 const extendedSliderItems = [ ...sliderItems.slice(-1), ...sliderItems, ...sliderItems.slice(0,1) ]; const handleClick = (direction) => { if(direction === "left"){ setSlideIndex(prev => prev - 1); }else{ setSlideIndex(prev => prev + 1); } }; useEffect(() => { const handleTransitionEnd = () => { if(slideIndex === 0){ setSlideIndex(sliderItems.length); wrapperRef.current.style.transition = 'none'; requestAnimationFrame(() => { wrapperRef.current.style.transition = 'transform 1.5s ease-in-out'; }); } if(slideIndex === sliderItems.length + 1){ setSlideIndex(1); wrapperRef.current.style.transition = 'none'; requestAnimationFrame(() => { wrapperRef.current.style.transition = 'transform 1.5s ease-in-out'; }); } }; const wrapper = wrapperRef.current; wrapper?.addEventListener('transitionend', handleTransitionEnd); return () => wrapper?.removeEventListener('transitionend', handleTransitionEnd); }, [slideIndex, sliderItems.length]); return ( <Container> <Arrow direction="left" onClick={() => handleClick("left")}> <ArrowLeftIcon /> </Arrow> <Wrapper ref={wrapperRef} slideIndex={slideIndex}> {extendedSliderItems.map((item, idx) => ( <Slide key={`${item.id}-${idx}`}> <ImgContainer> <Image src={item.img} alt={item.alt}/> </ImgContainer> <InfoContainer> <Title>{item.title}</Title> <Desc>{item.desc}</Desc> <Button>Shop Now</Button> </InfoContainer> </Slide> ))} </Wrapper> <Arrow direction="right" onClick={() => handleClick("right")}> <ArrowRightIcon /> </Arrow> </Container> ) } export default Slider;
原理说明
- 通过在真实滑块的前后各复制一个滑块,让滚动到边界时看起来是继续滚动到下一个/上一个滑块;
- 在滚动动画结束后,瞬间将索引重置到对应的真实滑块位置,并临时关闭过渡动画,用户视觉上感知不到跳转,从而实现无缝的无限滚动效果。
内容的提问来源于stack exchange,提问作者harjit.kainth7
相关产品推荐
相关产品推荐

