为什么我最后放弃了监听滚动刷新 Overlay,而改用 CompositedTransformFollower 来实现下拉浮层跟随。
Overlay 浮层最容易让人产生错觉的地方在于:
它看起来像是挂在某个按钮下面,实际上却已经脱离了原来的 Widget 布局树。
在 Flutter 里封装下拉菜单、Select、Popover、Tooltip 这类组件时,OverlayEntry 几乎是绕不开的方案。
弹窗、遮罩逻辑通常以下几个步骤组成
- 点击按钮;
- 获取按钮在屏幕上的坐标;
- 插入一个
OverlayEntry; - 用
Positioned把浮层放到按钮下面。
看起来没什么问题,直到这个组件被放进 ListView``SingleChildScrollView、弹窗页、复杂表单页里。页面一滚动,按钮走了,浮层没走。
直觉上的解决方案:
监听滚动,每次滚动都重新计算坐标,重新刷新 Overlay
问题背景
先看一个典型结构。
markdown
页面 Widget 树
Scaffold
└── ListView
├── Item 0
├── Item 1
├── SelectButton
├── Item 3
└── Item 4
Overlay 层
Overlay
└── OverlayEntry
└── DropdownPanel
从视觉上看,DropdownPanel 像是属于 SelectButton。
但从 Flutter 的结构上看,它们并不是父子关系。
SelectButton 在 ListView 里,会随着滚动发生位移;
DropdownPanel 在 Overlay 里,如果你只是在打开时计算一次位置,它并不会自动跟着按钮移动。
最常见的初版实现大概是这样:
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
第一种方案很直观:
- 使用
ScrollController或NotificationListener监听滚动; - 每次滚动时调用
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 就会变得很不划算。
更麻烦的是,这个方案强依赖 GlobalKey 和 BuildContext。
比如:
- 目标组件被列表回收了怎么办?
currentContext为空怎么办?- 页面里有多个滚动容器怎么办?
- 嵌套滚动时监听哪一个 ScrollController?
- Select 组件被封装后,业务方是否还要传滚动控制器?
这些问题都会让一个本来应该很轻的下拉菜单,慢慢变成一坨需要到处兜底的逻辑。
方案二:CompositedTransformFollower 自动跟随
第二种方案是使用 Flutter 提供的组合:
CompositedTransformTargetCompositedTransformFollowerLayerLink
核心思想是:
目标组件和 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。只有在确实需要完全自定义坐标策略时,才考虑监听滚动手动刷新。
场景适用方案输出
这里还要区分两个容易混在一起的问题:
- 滚动时浮层是否要跟随目标组件?
- 滚动时浮层是否应该直接关闭?
这两个问题不是一回事。
比如 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 来说,我们真正需要的不是"滚动时重新算一遍位置",而是"声明这个浮层应该跟随哪个目标"。
这正是 CompositedTransformTarget 和 CompositedTransformFollower 擅长的事情。
所以我的建议是:
| 结论 | 说明 |
|---|---|
| 浮层要跟随目标 | 优先使用 CompositedTransformFollower |
| 滚动后要关闭浮层 | 使用滚动通知处理关闭逻辑 |
| 不要把定位逻辑暴露给业务页面 | 公共组件内部维护 LayerLink 和 Overlay 生命周期 |
| 手动刷新不是首选 | 它更适合作为特殊坐标需求下的兜底方案 |
一句话总结:
能交给 Flutter Layer 体系处理的跟随关系,就不要让业务代码在滚动监听里反复追着坐标跑。