如何用Framer Motion实现含内部滚动组件的React页面滚动进度条?
问题描述
我正在用Framer Motion构建一个滚动进度条,需要追踪包含多组件的React页面的整体滚动进度。页面中有部分组件设置了min-h-screen且开启了overflow-y: scroll,希望进度条能把这些内部组件的滚动也计入整体进度(假设用户会先滚完内部组件再进入下一个区块)。但当前进度条加载后直接显示100%,完全不随滚动变化。
当前页面代码如下:
'use client'; import React, { useState, useEffect, useRef } from 'react'; import { motion, useScroll, useTransform } from 'framer-motion'; import Section1 from './Section1'; import Section2 from './Section2'; import Section3 from './Section3'; import Section4 from './Section4'; import Section5 from './Section5'; import Section6 from './Section6'; import Section7 from './Section7'; import Section8 from './Section8'; import Section9 from './Section9'; import Section10 from './Section10'; import Section11 from './Section11'; import Section12 from './Section12'; import Section13 from './Section13'; import Section14 from './Section14'; import Section15 from './Section15' import Qanda from './Qanda'; import Tldr from './Tldr'; import Landscape from './Landscape'; import Navigation from '../../_components/Navigation'; import { usePageTitle } from '../../_hooks/usePageTitle'; const ScrollProgressBar = () => { const { scrollYProgress } = useScroll(); // Track scroll progress const scaleX = useTransform(scrollYProgress, [0, 1], [0, 1]); // Transform scroll progress to scale return ( <motion.div style={{ position: 'fixed', top: 0, left: 0, height: '4px', width: '100%', backgroundColor: 'red', scaleX, // Apply the scale based on scroll progress transformOrigin: '0%', // Scale from the left zIndex: 1000, // Ensure it's above other content }} /> ); }; export default function CasePage() { const title = usePageTitle('DONOGHUE v STEVENSON'); const [activeSection, setActiveSection] = useState(1); const sectionsRef = useRef<Array<HTMLDivElement | null>>([]); const scrollableRef = useRef(null); useEffect(() => { const handleScroll = () => { const scrollPosition = window.scrollY + window.innerHeight / 2; for (let i = sectionsRef.current.length - 1; i >= 0; i--) { const section = sectionsRef.current[i]; if (section && section.offsetTop <= scrollPosition) { setActiveSection(i + 1); break; } } }; window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); const scrollToSection = (sectionId: string) => { const element = document.getElementById(sectionId); if (element) { element.scrollIntoView({ behavior: 'smooth' }); } }; return ( <div className="relative w-full bg-parchment"> <ScrollProgressBar /> <Navigation title={title} scrollToSection={scrollToSection} /> <div ref={el => { sectionsRef.current[0] = el; }} id="section1" className="section min-h-screen"> <Section1 scrollToSection={scrollToSection} /> </div> <div ref={el => { sectionsRef.current[1] = el; }} id="section2" className="section min-h-screen"> <Section2 /> </div> <div ref={el => { sectionsRef.current[2] = el; }} id="tldr" className="section"> <Tldr /> </div> <div ref={el => { sectionsRef.current[3] = el; }} id="landscape" className="section"> <Landscape /> </div> <div ref={el => { sectionsRef.current[4] = el; }} id="section3" className="section"> <Section3 /> </div> <div ref={el => { sectionsRef.current[5] = el; }} id="section4" className="section min-h-screen"> <Section4 /> </div> <div ref={el => { sectionsRef.current[6] = el; }} id="section5" className="section z-2"> <Section5 /> </div> <div ref={el => { sectionsRef.current[7] = el; }} id="section6" className="section min-h-screen"> <Section6 /> </div> <div ref={el => { sectionsRef.current[8] = el; }} id="section7" className="section min-h-screen"> <Section7 /> </div> <div ref={el => { sectionsRef.current[9] = el; }} id="section8" className="section min-h-screen"> <Section8 /> </div> <div ref={el => { sectionsRef.current[10] = el; }} id="section15" className="section min-h-screen z-2"> <Section15 /> </div> <div ref={el => { sectionsRef.current[11] = el; }} id="section9" className="section min-h-screen"> <Section9 /> </div> <div ref={el => { sectionsRef.current[12] = el; }} id="section10" className="section min-h-screen"> <Section10 /> </div> <div ref={el => { sectionsRef.current[13] = el; }} id="section11" className="section min-h-screen"> <Section11 /> </div> <div ref={el => { sectionsRef.current[14] = el; }} id="section12" className="section min-h-screen"> <Section12 /> </div> <div ref={el => { sectionsRef.current[15] = el; }} id="section13" className="section min-h-screen"> <Section13 /> </div> <div ref={el => { sectionsRef.current[16] = el; }} id="qanda" className="section min-h-screen"> <Qanda /> </div> <div ref={el => { sectionsRef.current[17] = el; }} id="section14" className="section min-h-screen"> <Section14 /> </div> </div> ); }
能否实现这样的进度条:同时追踪页面整体滚动和内部滚动组件的进度,比如用户滚动到Section4时,进度条先追踪该组件的内部滚动,直到组件滚到底部,再继续追踪页面的整体滚动?
解决方案
问题根源
默认的useScroll()只监听window的滚动事件,但内部开启overflow-y: scroll的组件滚动时,window的scrollY不会变化,导致进度条无法感知这部分滚动。另外,当前代码没有计算内部滚动组件的可滚动长度,所以进度条会直接显示100%。
实现思路
要实现整体进度追踪,需要:
- 计算总滚动长度:页面可滚动长度 + 所有内部可滚动组件的可滚动长度(
scrollHeight - clientHeight) - 监听window和所有内部可滚动组件的滚动事件
- 实时计算当前总滚动进度:页面滚动距离 + 已完成的内部组件滚动长度 + 当前内部组件的滚动距离,再除以总滚动长度
- 将计算好的进度传递给进度条组件,用Framer Motion实现动画
修改后的代码实现
1. 重构ScrollProgressBar为受控组件
const ScrollProgressBar = ({ progress }: { progress: number }) => { // 用useTransform实现平滑过渡动画 const scaleX = useTransform(progress, [0, 1], [0, 1]); return ( <motion.div style={{ position: 'fixed', top: 0, left: 0, height: '4px', width: '100%', backgroundColor: 'red', scaleX, transformOrigin: '0%', zIndex: 1000, }} /> ); };
2. 修改CasePage组件,添加滚动监听和进度计算
export default function CasePage() { const title = usePageTitle('DONOGHUE v STEVENSON'); const [activeSection, setActiveSection] = useState(1); const [totalProgress, setTotalProgress] = useState(0); const sectionsRef = useRef<Array<HTMLDivElement | null>>([]); // 内部可滚动组件的ref(这里以Section4为例) const section4Ref = useRef<HTMLDivElement>(null); // 计算总滚动长度 const calculateTotalScrollLength = () => { const pageScrollLength = document.body.scrollHeight - window.innerHeight; // Section4的可滚动长度 const section4ScrollLength = section4Ref.current ? section4Ref.current.scrollHeight - section4Ref.current.clientHeight : 0; // 如果有其他内部滚动组件,在这里累加 return pageScrollLength + section4ScrollLength; }; // 计算当前总进度 const calculateCurrentProgress = () => { const totalLength = calculateTotalScrollLength(); if (totalLength === 0) return 0; const section4 = section4Ref.current; if (!section4) return window.scrollY / totalLength; // Section4的位置信息 const section4OffsetTop = section4.offsetTop; const section4ScrollLength = section4.scrollHeight - section4.clientHeight; const windowScrollY = window.scrollY; const section4ScrollTop = section4.scrollTop; let currentProgress = 0; // 情况1:还没滚动到Section4 if (windowScrollY + window.innerHeight < section4OffsetTop) { currentProgress = windowScrollY / totalLength; } // 情况2:正在滚动Section4内部 else if (section4ScrollTop < section4ScrollLength) { // 页面滚动到Section4顶部的距离 const scrollToSection4Top = Math.max(0, section4OffsetTop - window.innerHeight); // 总进度 = 页面滚动到Section4的进度 + Section4内部滚动的进度 currentProgress = (scrollToSection4Top + section4ScrollTop) / totalLength; } // 情况3:Section4已滚完,继续滚动页面 else { // 总进度 = 页面滚动到Section4的进度 + Section4内部全滚完的进度 + 后续页面滚动的进度 const scrollToSection4Top = Math.max(0, section4OffsetTop - window.innerHeight); const postSection4Scroll = windowScrollY - scrollToSection4Top; currentProgress = (scrollToSection4Top + section4ScrollLength + postSection4Scroll) / totalLength; } // 进度限制在0-1之间 return Math.min(Math.max(currentProgress, 0), 1); }; // 监听滚动和窗口 resize useEffect(() => { const updateProgress = () => { setTotalProgress(calculateCurrentProgress()); }; // 监听window滚动 window.addEventListener('scroll', updateProgress); // 监听Section4内部滚动 section4Ref.current?.addEventListener('scroll', updateProgress); // 窗口resize时重新计算 window.addEventListener('resize', updateProgress); // 初始化进度 updateProgress(); return () => { window.removeEventListener('scroll', updateProgress); section4Ref.current?.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); // 原有的activeSection监听逻辑 useEffect(() => { const handleScroll = () => { const scrollPosition = window.scrollY + window.innerHeight / 2; for (let i = sectionsRef.current.length - 1; i >= 0; i--) { const section = sectionsRef.current[i]; if (section && section.offsetTop <= scrollPosition) { setActiveSection(i + 1); break; } } }; window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); const scrollToSection = (sectionId: string) => { const element = document.getElementById(sectionId); if (element) { element.scrollIntoView({ behavior: 'smooth' }); } }; return ( <div className="relative w-full bg-parchment"> <ScrollProgressBar progress={totalProgress} /> <Navigation title={title} scrollToSection={scrollToSection} /> {/* 其他section保持不变,修改Section4的ref */} <div ref={el => { sectionsRef.current[5] = el; }} id="section4" className="section min-h-screen"> {/* 假设Section4内部有可滚动容器,把ref挂到这个容器上 */} <div ref={section4Ref} style={{ height: '100%', overflowY: 'scroll' }}> <Section4 /> </div> </div> {/* 其余section代码保持不变 */} </div> ); }
注意事项
- 如果有多个内部滚动组件,需要给每个组件添加ref,在
calculateTotalScrollLength和calculateCurrentProgress中逐一处理它们的滚动状态 - 确保内部滚动组件的容器设置了
height: 100%和overflow-y: scroll,这样scrollHeight和clientHeight的计算才准确 - 进度计算中加入了边界判断,避免进度超出0-1的范围
内容的提问来源于stack exchange,提问作者Mel
相关产品推荐
相关产品推荐

