flutter_portal:进化版的 Overlay
/OverlayEntry
- 声明式而非命令式,直观的上下文,以及简易的对齐
想要显示浮动覆盖层 - 工具提示、上下文菜单、对话框、气泡等?这个库是对 Flutter 内置 Overlay/OverlayEntry 的增强和替代。
🚀 优势
为什么使用 flutter_portal
而不是内置的 Overlay/OverlayEntry/OverlayPortal?
- 声明式,非命令式:与 Flutter 世界中的其他一切一样,覆盖层(portals)现在是声明式的。只需在普通的 widget 树中放置您的浮动 UI。对比:OverlayEntry 不是一个 widget,需要通过
.insert()
等方式进行命令式操作。 - 轻松实现对齐:内置支持将覆盖层与 UI 组件对齐。对比:用几行代码就能从头开始创建自定义上下文菜单;而 Overlay 使得将工具提示/菜单与 widget 对齐变得不那么简单。
- 可自定义的对齐逻辑:例如,确保 portal 目标永远不会在屏幕外渲染(
shiftToWithinBound
),将其与 portal 而不是父 widget 对齐(alignToPortal
),您甚至可以创建自己的对齐算法(扩展EnhancedCompositedTransformAnchor
)。对比:Overlay 似乎没有这种功能。 - 直观的
Context
:覆盖层条目使用其直观的父级作为context
进行构建。对比:Overlay 方法使用远处的覆盖层作为其context
。更新:OverlayPortal(受本包启发)在这方面有所改进。
因此,还具有以下优点:
- 轻松实现可恢复属性:由于显示覆盖层就像执行
setState
一样简单,RestorableProperty
可以很好地工作。对比:使用 Overlay 方法时,当应用程序被操作系统终止时,我们的模态框状态不会被恢复。 - 正确的
Theme
/provider
:由于覆盖层条目具有直观的context
,它可以访问与显示覆盖层的 widget 相同的Theme
和不同的provider
。对比:Overlay 方法会产生令人困惑的 Theme 和 provider。更新:OverlayPortal(受本包启发)在这方面有所改进。
👀 展示代码
PortalTarget(
// 1. 声明式:只需将 `portalFollower` 作为普通 widget 提供
// 2. 内部具有直观的 BuildContext
portalFollower: MyAwesomeOverlayWidget(),
// 3. 可以随意将"follower"相对于"child"对齐
anchor: Aligned.center,
child: MyChildWidget(),
)
要从 0.x 迁移到 1.x,请参阅 readme 的最后一节。
🪜 示例
查看 examples
文件夹以了解如何使用 flutter_portal 的示例:
部分截图:
上下文菜单 | 引导视图 |
---|---|
🧭 使用方法
- 安装。按照安装此包的标准步骤进行。最简单的方法可能是
flutter pub add flutter_portal
。 - 添加 Portal widget。例如,将其放在
MaterialApp
之上。每个应用程序只需要一个 Portal。 - 在需要显示覆盖层的地方使用 PortalTarget。
📚 教程:显示上下文菜单
在这个例子中,我们将看到如何使用 flutter_portal 在点击 RaisedButton
后显示菜单。
添加 Portal widget
在做任何事情之前,您必须在 widget 树中插入 Portal widget。follower widget 将表现得好像它们是作为这个 widget 的子项插入的。
您可以将这个 Portal 放在 MaterialApp
之上或靠近路由的根部:
Portal(
child: MaterialApp(...)
)
按钮
首先,我们需要创建一个渲染 RaisedButton
的 StatefulWidget
:
class MenuExample extends StatefulWidget {
@override
_MenuExampleState createState() => _MenuExampleState();
}
class _MenuExampleState extends State<MenuExample> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: RaisedButton(
onPressed: () {},
child: Text('show menu'),
),
),
);
}
}
菜单 - 初始迭代
然后,我们需要在 widget 树中插入 PortalTarget。
我们希望上下文菜单紧靠 RaisedButton
渲染。
因此,我们的 PortalTarget 应该是 RaisedButton
的父级,如下所示:
child: PortalTarget(
visible: // TODO
anchor: // TODO
portalFollower: // TODO
child: RaisedButton(...),
),
我们可以将菜单传递给 PortalTarget:
PortalTarget(
visible: true,
anchor: Filled(),
portalFollower: Material(
elevation: 8,
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text('option 1')),
ListTile(title: Text('option 2')),
],
),
),
),
child: RaisedButton(...),
)
在这个阶段,您可能会注意到两件事:
- 我们的菜单是全屏的(因为
anchor
是Filled
) - 我们的菜单始终可见(因为
visible
是 true)
更改对齐方式
让我们先解决全屏问题,并更改我们的代码,使菜单在 RaisedButton
的右侧渲染。
要将菜单对齐到按钮周围,我们可以更改 anchor
参数:
PortalTarget(
visible: true,
anchor: const Aligned(
follower: Alignment.topLeft,
target: Alignment.topRight,
),
portalFollower: Material(...),
child: RaisedButton(...),
)
这段代码的意思是,将菜单的左上角与 `RaisedButton` 的右上角对齐。这样,我们的菜单就不再是全屏的,而是位于按钮的右侧。
显示菜单
最后,我们可以更新代码,使菜单只在点击按钮时显示。
为此,我们需要在 StatefulWidget
中声明一个新的布尔值,表示菜单是否打开:
class _MenuExampleState extends State<MenuExample> {
bool isMenuOpen = false;
...
}
然后,我们将这个 isMenuOpen
变量传递给 PortalEntry:
PortalTarget(
visible: isMenuOpen,
...
)
接着,在 RaisedButton
的 onPressed
回调中,我们可以更新这个 isMenuOpen
变量:
RaisedButton(
onPressed: () {
setState(() {
isMenuOpen = true;
});
},
child: Text('show menu'),
),
隐藏菜单
最后一步是在用户点击菜单外部时关闭菜单。
这可以通过结合使用第二个 PortalEntry 和 [GestureDetector] 来实现,如下所示:
PortalTarget(
visible: isMenuOpen,
portalFollower: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
isMenuOpen = false;
});
},
),
...
),
🎼 概念
在使用flutter_portal
时,有几个概念需要充分理解。特别是如果你想支持自定义用例,这在提供的抽象API中是很容易实现的。
以下将高层次地解释你需要理解的每个抽象概念。你会在类名(如Portal
部件或PortalTarget
部件)以及参数名中找到它们。
Portal(传送门)
Portal(或者如果你只有一个的话,就是the portal)是用于进行所有portal工作的空间。在底层,这意味着你有一个部件,允许其子树放置相互连接的目标和跟随者。
Portal还定义了可供任何跟随者在屏幕上渲染的可用区域(矩形边界)。
具体来说,你可能会将整个MaterialApp
包裹在一个单独的Portal
部件中,这意味着你可以使用应用的整个区域来渲染附加到Portal
部件子级的目标的跟随者。
Target(目标)
目标是portal内可以被跟随者跟随的任何位置。这允许你将任何你想要叠加的内容附加到UI中的特定位置,无论它如何动态移动。
在底层,这意味着你将UI中你想要跟随的部分包裹在一个PortalTarget
部件中并进行配置。
示例
想象你想在应用中当头像被悬停时显示工具提示。在这种情况下,头像将是portal的目标,可用于锚定叠加的工具提示。
另一个例子是下拉菜单。显示当前选择的部件是目标,当点击它时,下拉选项将通过portal作为跟随者叠加显示。
Follower(跟随者)
跟随者只能与目标结合使用。你可以将其用于任何你想要叠加在UI顶部的内容,附加到目标上。
具体来说,这意味着你可以为每个PortalTarget
传递一个follower
,当你指定时,它将显示在UI上方的portal内。
示例
如果你想使用flutter_portal
显示一个自动完成文本字段,你会想要跟随文本字段来叠加你的自动完成建议。在这种情况下,自动完成建议的部件将是portal的跟随者。
Anchor(锚点)
锚点定义了目标和跟随者之间的布局连接。通常,锚点被实现为一个抽象API,提供支持任何你想要的定位所需的所有信息。这意味着锚点可以基于相关portal、目标和跟随者的属性来定义。
默认实现了一些锚点,例如Aligned
或Filled
。
⛵ 从0.x版本迁移
从0.x到1.0版本有一些破坏性变更(主要由#44引入),但可以轻松迁移。以下是示例:
PortalEntry(
portalAnchor: Alignment.topLeft,
childAnchor: Alignment.topRight,
portal: MyAwesomePortalWidget(),
child: MyAwesomeChildWidget(),
)
变为:
PortalTarget(
anchor: const Aligned(
follower: Alignment.topLeft,
target: Alignment.topRight,
),
portalFollower: MyAwesomePortalWidget(),
child: MyAwesomeChildWidget(),
)
如果你原本使用PortalEntry
时没有设置portalAnchor
/childAnchor
(即使其全屏显示),那么你可以这样写:
PortalTarget(
anchor: const Filled(),
...
)
✨ 致谢
所有者
- @rrousselGit:这个包的前任所有者。于2019年12月创建这个包,并主要维护到2022年初。贡献包括:实现包的功能,包括代码、文档、示例等。更改渲染算法。移除PortalEntry的泛型。允许延迟PortalEntry的消失,对离开动画很有用。
- @fzyzcjy:这个包的现任所有者。详见
CHANGELOG.md
了解贡献。
贡献者
- @creativecreatorormaybenot:为高级用例提供新的锚定逻辑,使锚点更灵活,改进代码质量,并在不增加额外布局/绘制调用的情况下增强非脆弱性。
- @Jjagg:迁移到NNBD。
- @CaseyHillers:使示例与Dart 3兼容。
- @srawlins:更新分析器以使其与Dart 3兼容。
- @mono0926:更新依赖和文档。
- @tepcii和@nilsreichardt:修复文档。
- @mityax:修复导出。