React中如何让Floating UI固定后的弹窗随容器滚动
解决Floating UI固定弹窗随容器滚动的问题
我来帮你搞定这个问题!你遇到的核心问题是:当弹窗被固定后,Floating UI依然基于视口坐标更新位置,并且默认的autoUpdate会在容器滚动时触发更新,导致弹窗“钉”在视口而不是随容器滚动。下面是具体的解决方案和代码修改:
问题根源分析
- 你当前的参考元素(鼠标/固定位置)使用的是
clientX/clientY,这是相对于视口的坐标,容器滚动时视口坐标不会随滚动偏移,导致弹窗位置不变。 - 默认的
autoUpdate会监听视口滚动、元素大小变化等所有触发条件,即使弹窗固定后仍会持续更新位置。
解决方案步骤
1. 添加滚动容器引用
首先需要获取弹窗所在滚动容器的DOM引用,用来计算相对坐标和监听滚动事件:
const scrollContainerRef = useRef<HTMLElement>(null);
记得在你的滚动容器组件上绑定这个ref:
<div ref={scrollContainerRef} style={{ position: 'relative', overflow: 'auto' }}> {/* 你的页面内容,包括弹窗 */} </div>
⚠️ 必须给滚动容器设置position: relative,这样弹窗的absolute定位才会相对于容器,而不是视口。
2. 自定义autoUpdate逻辑
当弹窗被固定时,我们只需要监听滚动容器的滚动事件,而不是默认的所有触发条件,避免不必要的位置更新:
const { refs, floatingStyles, update } = useFloating({ strategy: "absolute", placement: "top-end", middleware: [ offset(({ placement }) => { return { mainAxis: 20, crossAxis: getAlignment(placement) === "start" ? 20 : -20 }; }), flip({ mainAxis: true, crossAxis: true }), shift({ mainAxis: true, crossAxis: true }), ], // 自定义whileElementsMounted逻辑 whileElementsMounted: (referenceEl, floatingEl, updateFn) => { if (pinnedAt && scrollContainerRef.current) { const container = scrollContainerRef.current; // 只在容器滚动时更新位置 const handleScroll = () => updateFn(); container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); } else { // 非固定状态下使用默认的autoUpdate return autoUpdate(referenceEl, floatingEl, updateFn); } }, });
3. 修正固定位置的坐标计算
将固定位置的坐标从视口相对转换为容器相对,这样容器滚动时,弹窗会跟着容器的滚动偏移更新位置:
// 转换视口坐标为容器相对坐标 const [relativePinnedPos, setRelativePinnedPos] = useState<{x: number; y: number} | undefined>(undefined); useEffect(() => { if (pinnedAt && scrollContainerRef.current) { const container = scrollContainerRef.current; const containerRect = container.getBoundingClientRect(); // 计算相对于容器内部的坐标(考虑容器已滚动的距离) setRelativePinnedPos({ x: pinnedAt.x - containerRect.left + container.scrollLeft, y: pinnedAt.y - containerRect.top + container.scrollTop, }); } else { setRelativePinnedPos(undefined); } }, [pinnedAt, scrollContainerRef]); // 更新参考元素的getBoundingClientRect逻辑 useEffect(() => { refs.setReference({ getBoundingClientRect() { if (relativePinnedPos && scrollContainerRef.current) { const container = scrollContainerRef.current; const containerRect = container.getBoundingClientRect(); // 计算相对于视口的坐标(结合容器的滚动偏移) const absoluteX = containerRect.left + relativePinnedPos.x - container.scrollLeft; const absoluteY = containerRect.top + relativePinnedPos.y - container.scrollTop; return { x: absoluteX, y: absoluteY, top: absoluteY, left: absoluteX, right: absoluteX, bottom: absoluteY, width: 0, height: 0, }; } else { // 非固定状态下仍使用鼠标的视口坐标 const pos = mouseRef.current; return { x: pos.x, y: pos.y, top: pos.y, left: pos.x, right: pos.x, bottom: pos.y, width: 0, height: 0, }; } }, }); }, [refs, relativePinnedPos, scrollContainerRef]);
4. 调整鼠标移动监听逻辑(保持现有即可)
你当前的鼠标监听已经处理了固定状态下不更新的逻辑,无需修改:
useEffect(() => { function handleMouseMove(e: MouseEvent) { mouseRef.current = { x: e.clientX, y: e.clientY }; if (pinnedAt) { return; } update(); } window.addEventListener("mousemove", handleMouseMove); return () => { window.removeEventListener("mousemove", handleMouseMove); }; }, [update, pinnedAt]);
最终修改后的完整组件代码
import scopedCss from '@/components/TechNodePopup.module.css'; import { useEffect, useRef, useState } from "react"; import clsx from "clsx"; import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react"; import { getAlignment } from "@floating-ui/utils"; import type { AbilityType } from "@/types/AbilityType"; import type { TechNodeWithMeta } from "@/types/TechNodeWithMeta"; import TechNodePopupContent from './TechNodePopupContent'; export interface TechNodePopupProps { abilityType: AbilityType; abilityName?: string; nodeData: TechNodeWithMeta; getTechNameFromId: (id: string) => string; initialX: number; initialY: number; pinnedAt?: { x: number; y: number }; // 添加滚动容器的ref传递(或者在组件内部获取,根据你的结构调整) scrollContainerRef: React.RefObject<HTMLElement>; } export default function TechNodePopup({ abilityType, abilityName, nodeData, getTechNameFromId, initialX, initialY, pinnedAt, scrollContainerRef }: TechNodePopupProps) { const mouseRef = useRef({ x: initialX, y: initialY }); const [relativePinnedPos, setRelativePinnedPos] = useState<{x: number; y: number} | undefined>(undefined); const { refs, floatingStyles, update, } = useFloating({ strategy: "absolute", placement: "top-end", middleware: [ offset(({ placement }) => { return { mainAxis: 20, crossAxis: getAlignment(placement) === "start" ? 20 : -20 }; }), flip({ mainAxis: true, crossAxis: true, }), shift({ mainAxis: true, crossAxis: true, }), ], whileElementsMounted: (referenceEl, floatingEl, updateFn) => { if (pinnedAt && scrollContainerRef.current) { const container = scrollContainerRef.current; const handleScroll = () => updateFn(); container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); } else { return autoUpdate(referenceEl, floatingEl, updateFn); } }, }); // 转换视口固定坐标为容器相对坐标 useEffect(() => { if (pinnedAt && scrollContainerRef.current) { const container = scrollContainerRef.current; const containerRect = container.getBoundingClientRect(); setRelativePinnedPos({ x: pinnedAt.x - containerRect.left + container.scrollLeft, y: pinnedAt.y - containerRect.top + container.scrollTop, }); } else { setRelativePinnedPos(undefined); } }, [pinnedAt, scrollContainerRef]); // Virtual element representing the mouse cursor or pinned position useEffect(() => { refs.setReference({ getBoundingClientRect() { if (relativePinnedPos && scrollContainerRef.current) { const container = scrollContainerRef.current; const containerRect = container.getBoundingClientRect(); const absoluteX = containerRect.left + relativePinnedPos.x - container.scrollLeft; const absoluteY = containerRect.top + relativePinnedPos.y - container.scrollTop; return { x: absoluteX, y: absoluteY, top: absoluteY, left: absoluteX, right: absoluteX, bottom: absoluteY, width: 0, height: 0, }; } else { const pos = mouseRef.current; return { x: pos.x, y: pos.y, top: pos.y, left: pos.x, right: pos.x, bottom: pos.y, width: 0, height: 0, }; } }, }); }, [refs, relativePinnedPos, scrollContainerRef]); // Get floating element to position itself based on mouse cursor upon mouse movement useEffect(() => { function handleMouseMove(e: MouseEvent) { mouseRef.current = { x: e.clientX, y: e.clientY }; if (pinnedAt) { return; } update(); } window.addEventListener("mousemove", handleMouseMove); return () => { window.removeEventListener("mousemove", handleMouseMove); }; }, [update, pinnedAt]); // Calc tech type class (needed here and in content component) let techTypeClass = ""; if (abilityType === "tech") { techTypeClass = clsx(nodeData.techType && `tech-${nodeData.techType.toLowerCase()}`); } return ( <div ref={refs.setFloating} className={clsx("techNode", scopedCss.techNode, "selected", "highlighted", techTypeClass, pinnedAt && scopedCss.pinned)} style={{ ...floatingStyles, width: "410px", zIndex: 999, }} > <TechNodePopupContent abilityType={abilityType} abilityName={abilityName} techTypeClass={techTypeClass} nodeData={nodeData} getTechNameFromId={getTechNameFromId} /> </div> ); }
关键注意点
- 确保滚动容器设置了
position: relative,否则absolute定位的弹窗会相对于视口而不是容器。 - 如果你的
pinnedAt参数本身就是相对于容器的坐标,那可以跳过坐标转换的步骤,直接使用该值计算视口坐标。 - 自定义的
whileElementsMounted逻辑只在固定状态下监听容器滚动,避免了不必要的性能消耗。
内容的提问来源于stack exchange,提问作者Jez
相关产品推荐
相关产品推荐

