You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Framer Motion拖拽轮播DragEnd幻灯片居中吸附问题

Framer Motion 拖拽轮播松手吸附问题修复

问题核心原因

  • 拖拽结束触发onDragEnd时,currentSlide状态未同步拖拽后的实际位置,仍保留拖拽前的旧值,导致handleSlideSnap计算的吸附位置完全错误
  • 测量宽度的ref错误绑定在单个幻灯片元素上,useMeasure返回的宽度会被最后渲染的幻灯片覆盖,无法得到稳定的单页宽度值,偏移量计算本身存在误差
  • 幻灯片切换完全依赖onViewportEnter的视口检测,拖拽速度较快时视口回调触发滞后,无法及时更新当前页索引
  • 拖拽操作和自动轮播逻辑没有互斥,用户拖拽过程中自动轮播定时器仍在运行,会直接打断拖拽状态

修复后完整代码

const BannnerCarousel = ({ slice }) => {
    const [width, setWidth] = useState(0);
    const [currentSlide, setCurrentSlide] = useState(0);
    const [isDragging, setIsDragging] = useState(false);
    const carousel = useRef();
    const slideContainerRef = useRef();
    const x = useMotionValue(0);
    // 固定取第一个幻灯片宽度作为单页标准宽度,避免单元素测量误差
    const [containerRef, { width: slideWidth }] = useMeasure();

    const totalSlides = slice?.items?.length - 1;
    const speed = slice?.primary?.speed || 8;

    // 带平滑动画吸附到指定幻灯片
    const snapToSlide = (index) => {
        const targetIndex = Math.max(0, Math.min(index, totalSlides));
        setCurrentSlide(targetIndex);
        x.start(-slideWidth * targetIndex, {
            type: "spring",
            stiffness: 300,
            damping: 30
        });
    };

    const nextSlide = useCallback(() => {
        if (isDragging) return; // 拖拽时禁止自动轮播触发
        setCurrentSlide(prev => prev === totalSlides ? 0 : prev + 1);
    }, [totalSlides, isDragging]);

    // 拖拽结束时根据偏移量、滑动速度计算目标吸附页
    const handleDragEnd = (e, { offset, velocity }) => {
        setIsDragging(false);
        const swipeThreshold = Math.abs(velocity.x) > 1 ? slideWidth / 4 : slideWidth / 2;
        let targetIndex = currentSlide;
        
        if (offset.x < -swipeThreshold) {
            targetIndex = currentSlide + 1;
        } else if (offset.x > swipeThreshold) {
            targetIndex = currentSlide - 1;
        }
        snapToSlide(targetIndex);
    };

    useEffect(() => {
        if (isDragging) return;
        const interval = setInterval(() => nextSlide(), speed * 1000);
        return () => clearInterval(interval);
    }, [nextSlide, speed, isDragging]);

    useEffect(() => {
        if (carousel.current) {
            setWidth(carousel.current.scrollWidth - carousel.current.offsetWidth);
        }
    }, [slideWidth]);

    useEffect(() => {
        if (!isDragging && slideWidth) {
            x.set(-slideWidth * currentSlide);
        }
    }, [currentSlide, slideWidth, isDragging, x]);

    return (
        <section>
            <motion.div
                ref={carousel}
                whileTap={{ cursor: 'grabbing' }}
                className='overflow-hidden cursor-grab'
            >
                <motion.div
                    ref={slideContainerRef}
                    drag='x'
                    dragConstraints={{ right: 0, left: -width }}
                    style={{ x }}
                    onDragStart={() => setIsDragging(true)}
                    onDragEnd={handleDragEnd}
                    className='flex'
                >
                    {slice.items.map((item, i) => (
                        <div key={i} className='relative'>
                            {item.image && (
                                <motion.div
                                    ref={i === 0 ? containerRef : null}
                                    className='relative min-h-[500px] md:min-h-[600px] w-screen'
                                >
                                    <Image
                                        src={item.image.url}
                                        alt={item.image?.alt || item?.title}
                                        layout='fill'
                                        objectFit='cover'
                                        priority={i === 0}
                                    />
                                </motion.div>
                            )}
                            <div className='absolute inset-0 w-full min-h-[500px] bg-black/60 md:min-h-[600px] flex items-end pt-24 pb-20'>
                                <div className='container h-full'>
                                    <div className='w-full h-full xl:w-1/2 flex flex-col justify-end'>
                                        {item.title && (
                                            <span className='block font-nunito font-extrabold text-2xl sm:text-3xl md:text-5xl text-white mb-4 uppercase leading-relaxed sm:leading-relaxed md:leading-snug text-center sm:text-start'>
                                                <PrismicRichText field={item.title} />
                                            </span>
                                        )}
                                        {item.description && (
                                            <span className='block font-source-serif-pro text-lg sm:text-xl md:text-2xl text-white mb-8 text-center sm:text-start leading-relaxed sm:leading-relaxed md:leading-relaxed'>
                                                <PrismicRichText field={item.description} />
                                            </span>
                                        )}
                                        {item.link && (
                                            <span className='flex justify-center sm:justify-start relative z-10'>
                                                <PrismicLink field={item.link}>
                                                    <span className='mx-auto flex font-source-serif-pro font-semibold text-base sm:text-xl md:text-2xl button'>
                                                        <PrismicRichText field={item?.buttonText} />
                                                    </span>
                                                </PrismicLink>
                                            </span>
                                        )}
                                    </div>
                                </div>
                            </div>
                        </div>
                    ))}
                </motion.div>
            </motion.div>

            <div className='flex gap-4 justify-center my-10 w-1/2 md:w-1/3 lg:w-1/4 mx-auto'>
                {slice?.items?.map((item, i) => (
                    <button
                        key={i}
                        aria-label={`跳转到第 ${i + 1} 张幻灯片`}
                        onClick={() => {
                            setIsDragging(false);
                            snapToSlide(i);
                        }}
                        style={{ width: `calc(100%/${totalSlides + 1})` }}
                        className={`h-2 ${
                            currentSlide === i ? 'bg-secondary-100' : 'bg-gray-400'
                        } transition-colors`}
                    ></button>
                ))}
            </div>
        </section>
    );
};

export default BannnerCarousel;

关键改动说明

  • 新增isDragging状态标记拖拽状态,拖拽期间暂停自动轮播,避免状态冲突
  • 宽度测量ref只绑定在第一个幻灯片元素上,确保拿到稳定的单页宽度,消除偏移量计算误差
  • 重写onDragEnd逻辑,结合拖拽偏移距离、滑动速度双维度判断翻页阈值,松手时直接计算目标页索引,移除滞后的视口检测逻辑
  • 吸附动画改用x.start()接入Framer Motion弹簧动画,替代生硬的数值跳转,过渡更自然
  • 修复底部指示器宽度计算错误,原逻辑总页数取值少1,会导致按钮宽度溢出
  • 所有页码变更统一收敛到snapToSlide方法处理,避免多入口更新导致的状态不同步

该实现完全基于Framer Motion原生API完成,不需要引入额外第三方库即可实现流畅的拖拽吸附效果

内容的提问来源于stack exchange,提问作者Roy Sheppard

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 01:39:18