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

React中如何让Floating UI固定后的弹窗随容器滚动

解决Floating UI固定弹窗随容器滚动的问题

我来帮你搞定这个问题!你遇到的核心问题是:当弹窗被固定后,Floating UI依然基于视口坐标更新位置,并且默认的autoUpdate会在容器滚动时触发更新,导致弹窗“钉”在视口而不是随容器滚动。下面是具体的解决方案和代码修改:

问题根源分析

  1. 你当前的参考元素(鼠标/固定位置)使用的是clientX/clientY,这是相对于视口的坐标,容器滚动时视口坐标不会随滚动偏移,导致弹窗位置不变。
  2. 默认的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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.28 06:40:02