翻江倒海——滚动布局下拉视图管理

为什么我最后放弃了监听滚动刷新 Overlay,而改用 CompositedTransformFollower 来实现下拉浮层跟随。

Overlay 浮层最容易让人产生错觉的地方在于:

它看起来像是挂在某个按钮下面,实际上却已经脱离了原来的 Widget 布局树。

在 Flutter 里封装下拉菜单、Select、Popover、Tooltip 这类组件时,OverlayEntry 几乎是绕不开的方案。

弹窗、遮罩逻辑通常以下几个步骤组成

  1. 点击按钮;
  2. 获取按钮在屏幕上的坐标;
  3. 插入一个 OverlayEntry
  4. Positioned 把浮层放到按钮下面。

看起来没什么问题,直到这个组件被放进 ListView``SingleChildScrollView、弹窗页、复杂表单页里。页面一滚动,按钮走了,浮层没走。

直觉上的解决方案:

监听滚动,每次滚动都重新计算坐标,重新刷新 Overlay


问题背景

先看一个典型结构。

markdown 复制代码
页面 Widget 树

Scaffold
└── ListView
    ├── Item 0
    ├── Item 1
    ├── SelectButton
    ├── Item 3
    └── Item 4

Overlay 层

Overlay
└── OverlayEntry
    └── DropdownPanel

从视觉上看,DropdownPanel 像是属于 SelectButton

但从 Flutter 的结构上看,它们并不是父子关系。

SelectButtonListView 里,会随着滚动发生位移;

DropdownPanelOverlay 里,如果你只是在打开时计算一次位置,它并不会自动跟着按钮移动。

最常见的初版实现大概是这样:

ini 复制代码
void showDropdown() {
  final renderBox = targetKey.currentContext!.findRenderObject() as RenderBox;
  final offset = renderBox.localToGlobal(Offset.zero);

  overlayEntry = OverlayEntry(
    builder: (context) {
      return Positioned(
        left: offset.dx,
        top: offset.dy + renderBox.size.height,
        child: dropdownPanel,
      );
    },
  );

  Overlay.of(context).insert(overlayEntry!);
}

这段代码的问题在于:

offset 只在打开浮层那一刻计算了一次。

如果后续页面滚动了,目标组件的位置变了,但 Overlay 里的浮层位置不会自动更新。

于是问题就变成了:

css 复制代码
打开时:

[ SelectButton ]
[ DropdownPanel ]

滚动后:

[ SelectButton ]   ← 已经移动
        ...
[ DropdownPanel ]  ← 还停在旧位置

这就是很多自定义 Select / Dropdown 组件在滚动容器里出现"浮层错位"的根源。


方案实现对比

方案一:监听滚动 + 手动刷新 Overlay

第一种方案很直观:

  • 使用 ScrollControllerNotificationListener 监听滚动;
  • 每次滚动时调用 overlayEntry.markNeedsBuild()
  • OverlayEntry.builder 里重新计算目标组件位置;
  • 用新的坐标更新 Positioned

核心代码如下:

scss 复制代码
final ScrollController scrollController = ScrollController();
final GlobalKey targetKey = GlobalKey();

OverlayEntry? overlayEntry;

@override
void initState() {
  super.initState();
  scrollController.addListener(_onScroll);
}

void _onScroll() {
  overlayEntry?.markNeedsBuild();
}

Overlay 中重新计算位置:

ini 复制代码
void showDropdown() {
  overlayEntry = OverlayEntry(
    builder: (context) {
      final targetContext = targetKey.currentContext;
      if (targetContext == null) {
        return const SizedBox.shrink();
      }

      final renderBox = targetContext.findRenderObject() as RenderBox;
      final offset = renderBox.localToGlobal(Offset.zero);

      return Positioned(
        left: offset.dx,
        top: offset.dy + renderBox.size.height,
        width: renderBox.size.width,
        child: Material(
          elevation: 6,
          child: dropdownPanel,
        ),
      );
    },
  );

  Overlay.of(context).insert(overlayEntry!);
}

整体运行策略可以理解为:

css 复制代码
flowchart TD
    A[用户滚动 ListView] --> B[ScrollController 触发监听]
    B --> C[调用 overlayEntry.markNeedsBuild]
    C --> D[OverlayEntry 重新执行 builder]
    D --> E[通过 GlobalKey 获取 RenderBox]
    E --> F[localToGlobal 重新计算坐标]
    F --> G[Positioned 更新浮层位置]

这个方案的优点是容易理解,写起来也比较直接。

但问题也很明显:

markdown 复制代码
滚动一次
  └── 触发监听
      └── 刷新 Overlay
          └── 查找 RenderObject
              └── 计算全局坐标
                  └── 重建浮层

如果只是简单 demo,看不出太大问题。

但一旦下拉面板内容复杂,比如包含搜索框、分组列表、图标、状态样式,滚动时反复 rebuild 就会变得很不划算。

更麻烦的是,这个方案强依赖 GlobalKeyBuildContext

比如:

  • 目标组件被列表回收了怎么办?
  • currentContext 为空怎么办?
  • 页面里有多个滚动容器怎么办?
  • 嵌套滚动时监听哪一个 ScrollController?
  • Select 组件被封装后,业务方是否还要传滚动控制器?

这些问题都会让一个本来应该很轻的下拉菜单,慢慢变成一坨需要到处兜底的逻辑。


方案二:CompositedTransformFollower 自动跟随

第二种方案是使用 Flutter 提供的组合:

  • CompositedTransformTarget
  • CompositedTransformFollower
  • LayerLink

核心思想是:

目标组件和 Overlay 浮层不通过 Widget 父子关系绑定,而是通过 LayerLink 在渲染层建立关联。

触发区域:

less 复制代码
final LayerLink layerLink = LayerLink();

CompositedTransformTarget(
  link: layerLink,
  child: GestureDetector(
    onTap: showDropdown,
    child: selectButton,
  ),
)

Overlay 浮层:

less 复制代码
void showDropdown() {
  overlayEntry = OverlayEntry(
    builder: (context) {
      return Positioned.fill(
        child: CompositedTransformFollower(
          link: layerLink,
          offset: const Offset(0, 40),
          showWhenUnlinked: false,
          child: Align(
            alignment: Alignment.topLeft,
            child: Material(
              elevation: 6,
              child: dropdownPanel,
            ),
          ),
        ),
      );
    },
  );

  Overlay.of(context).insert(overlayEntry!);
}

它的运行策略是另一套逻辑:

css 复制代码
flowchart TD
    A[SelectButton 使用 CompositedTransformTarget] --> B[绑定 LayerLink]
    C[Overlay 中的 DropdownPanel 使用 CompositedTransformFollower] --> B
    D[ListView 发生滚动] --> E[Target 所在 Layer 位置变化]
    E --> F[Follower 在合成阶段同步变换]
    F --> G[浮层自动跟随目标组件]

对比手动刷新方案,它不需要你在滚动时不断调用 markNeedsBuild()

可以粗略理解为:

markdown 复制代码
手动刷新方案:

滚动
└── Dart 层监听
    └── 手动 rebuild
        └── 重新算位置
            └── 更新浮层

Follower 方案:

滚动
└── 渲染层位置变化
    └── LayerLink 同步关系
        └── 合成阶段更新浮层变换

这也是它更适合 Overlay 浮层定位的原因。

它把"位置同步"这件事交给 Flutter 的 Layer 体系,而不是让业务代码追着滚动事件跑。


方案小结

这两个方案都能让下拉浮层看起来跟着按钮移动,但它们的职责边界完全不同。

监听滚动刷新 Overlay,本质上是:

业务层监听滚动,然后命令 Overlay 重新计算位置。

CompositedTransformFollower 本质上是:

组件声明目标和浮层之间的跟随关系,由 Flutter 在合成阶段完成位置同步。

可以用一张表概括:

对比项 监听滚动刷新 Overlay CompositedTransformFollower
实现思路 滚动时手动刷新 Overlay 通过 LayerLink 自动跟随
位置计算 GlobalKey + RenderBox.localToGlobal Flutter Layer 体系处理
滚动时行为 每次滚动可能触发 rebuild 通常不需要重建浮层
代码复杂度 监听、计算、兜底都要自己写 Target 和 Follower 配对即可
维护成本 容易受滚动容器、生命周期影响 更适合封装成公共组件
主要风险 context 为空、目标被回收、频繁刷新 需要正确绑定同一个 LayerLink
推荐程度 特殊场景兜底 常规下拉浮层优先推荐

所以我后来对这类组件的判断是:

只要需求是"浮层跟随某个目标组件",优先考虑 CompositedTransformFollower

只有在确实需要完全自定义坐标策略时,才考虑监听滚动手动刷新。


场景适用方案输出

这里还要区分两个容易混在一起的问题:

  1. 滚动时浮层是否要跟随目标组件?
  2. 滚动时浮层是否应该直接关闭?

这两个问题不是一回事。

比如 Select 组件有两种常见产品逻辑:

css 复制代码
逻辑 A:滚动时继续跟随

[ SelectButton ]
[ DropdownPanel ]

页面滚动后:

[ SelectButton ]
[ DropdownPanel ]  ← 继续贴着按钮


逻辑 B:滚动时直接关闭

[ SelectButton ]
[ DropdownPanel ]

页面滚动后:

[ SelectButton ]
                 ← DropdownPanel 关闭

如果是逻辑 A,应该用 CompositedTransformFollower 解决定位跟随。

如果是逻辑 B,可以额外监听滚动通知,在用户滚动时关闭浮层。

例如:

kotlin 复制代码
NotificationListener<ScrollNotification>(
  onNotification: (notification) {
    if (notification is ScrollStartNotification) {
      closeDropdown();
    }
    return false;
  },
  child: pageContent,
)

如果封装公共 Select,可以把它做成配置项:

php 复制代码
Select(
  closeOnScroll: true,
)

组件内部大致结构可以是:

scala 复制代码
class CustomSelect extends StatefulWidget {
  const CustomSelect({
    super.key,
    this.closeOnScroll = true,
  });

  final bool closeOnScroll;

  @override
  State<CustomSelect> createState() => _CustomSelectState();
}

class _CustomSelectState extends State<CustomSelect> {
  final LayerLink _layerLink = LayerLink();
  OverlayEntry? _overlayEntry;

  void _openDropdown() {
    _overlayEntry = OverlayEntry(
      builder: (context) {
        return Positioned.fill(
          child: CompositedTransformFollower(
            link: _layerLink,
            offset: const Offset(0, 40),
            showWhenUnlinked: false,
            child: Align(
              alignment: Alignment.topLeft,
              child: _buildDropdownPanel(),
            ),
          ),
        );
      },
    );

    Overlay.of(context).insert(_overlayEntry!);
  }

  void _closeDropdown() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  @override
  void dispose() {
    _closeDropdown();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return CompositedTransformTarget(
      link: _layerLink,
      child: _buildSelectButton(),
    );
  }
}

这里有一个设计原则很关键:

业务页面不应该关心下拉菜单如何计算坐标。

Select 组件应该自己管理 Overlay、LayerLink、打开关闭和生命周期。

业务方最好只关心:

less 复制代码
CustomSelect(
  value: value,
  options: options,
  onChanged: onChanged,
)

而不是被迫写:

php 复制代码
CustomSelect(
  scrollController: scrollController,
  targetKey: targetKey,
  onScrollRebuildOverlay: true,
)

后者说明组件封装边界已经开始泄漏了。


总结

最后用一张表总结不同场景下的推荐方案:

场景 推荐方案 原因
普通下拉菜单 CompositedTransformFollower 跟随目标组件,代码简洁,适合封装
Select / Dropdown 组件 CompositedTransformFollower 浮层定位属于组件内部职责
Tooltip / Popover CompositedTransformFollower 目标和浮层天然是一组跟随关系
页面滚动时浮层继续显示 CompositedTransformFollower 滚动时自动同步位置
页面滚动时浮层直接关闭 Follower + 滚动通知关闭 定位交给 Follower,交互关闭交给滚动监听
特殊吸附、自定义动画轨迹 手动计算坐标 需要完全控制浮层位置
多目标动态切换浮层 视情况使用 LayerLink 或手动计算 取决于目标关系是否稳定
复杂嵌套滚动场景 优先 Follower,必要时补充关闭策略 避免在多个滚动容器之间传递监听逻辑

这次重构下拉菜单后,我最大的感受是:

Overlay 浮层定位,真正麻烦的不是显示一个面板,而是处理它和目标组件之间的关系。

监听滚动刷新 Overlay 的方案并不是错,它适合一些高度自定义的位置计算场景。

但对于大多数下拉菜单、Select、Popover 来说,我们真正需要的不是"滚动时重新算一遍位置",而是"声明这个浮层应该跟随哪个目标"。

这正是 CompositedTransformTargetCompositedTransformFollower 擅长的事情。

所以我的建议是:

结论 说明
浮层要跟随目标 优先使用 CompositedTransformFollower
滚动后要关闭浮层 使用滚动通知处理关闭逻辑
不要把定位逻辑暴露给业务页面 公共组件内部维护 LayerLink 和 Overlay 生命周期
手动刷新不是首选 它更适合作为特殊坐标需求下的兜底方案

一句话总结:

能交给 Flutter Layer 体系处理的跟随关系,就不要让业务代码在滚动监听里反复追着坐标跑。

github.com/lizy-coding...

相关推荐
spmcor1 小时前
Flutter 学习笔记 (6):路由与导航 —— 从基础 push/pop 到 go_router
flutter
江畔柳前堤3 小时前
github实战指南05-Fork与开源协作
人工智能·线性代数·oracle·开源·github·word
放下华子我只抽RuiKe53 小时前
FastAPI 全栈后端(七):测试与自动化
运维·前端·人工智能·react.js·前端框架·自动化·fastapi
Keep_Trying_Go3 小时前
华为开源框架MindSpore基本使用
华为·开源
神奇的小猴程序员15 小时前
提升 AI 与开发效率!两款实用 Skill 开源工具 FunctionCool-Skill & StyleCool-Skill 深度体验
人工智能·开源·s
Cosolar15 小时前
Docsify零构建文档站完全指南:从快速搭建到企业级部署
前端·开源·github
分布式存储与RustFS20 小时前
基于Rust的国产开源对象存储RustFS:S3 Table对Iceberg数据湖的适配详解
rust·开源·iceberg·对象存储·rustfs·minio平替·s3 table
英勇无比的消炎药21 小时前
一站式汇总TinyVue工具案例与真实落地经验
vue.js·前端框架