OpenBB 是一个开源的金融数据平台,旨在为投资者、分析师、研究人员和开发者提供免费、透明且易于使用的金融与宏观经济数据访问接口。它曾被认为是类似于彭博终端(Bloomberg Terminal)的功能性替代品,但完全开放源码,用户可以自由定制和扩展。
在一些文章中将 OpenBB 解释为 Open Bloomberg,这是个误解。尽管它常被视为“开源版彭博终端”,但其名称中的“BB”实际上源自黑莓公司的股票代码,而 OpenBB 的创始人此前曾在黑莓股票上亏损。
OpenBB 是一个开源的金融数据平台,旨在为投资者、分析师、研究人员和开发者提供免费、透明且易于使用的金融与宏观经济数据访问接口。它曾被认为是类似于彭博终端(Bloomberg Terminal)的功能性替代品,但完全开放源码,用户可以自由定制和扩展。
在一些文章中将 OpenBB 解释为 Open Bloomberg,这是个误解。尽管它常被视为“开源版彭博终端”,但其名称中的“BB”实际上源自黑莓公司的股票代码,而 OpenBB 的创始人此前曾在黑莓股票上亏损。
当多个手势同时出现在同一控件上时,就会发生手势冲突。这可能会导致系统无法准确识别用户的意图,从而导致错误的操作。iOS 上的 UIScrollView 是手势冲突的主要来源,因为它可以响应滚动、缩放和点击等各种手势。当 Flutter 视图与 UIScrollView 同时存在时,很容易发生手势冲突。
最常见的场景是将 Flutter 视图嵌套在 UIScrollView 中。在这种情况下,UIScrollView 的滚动手势和 Flutter 视图的手势很容易发生冲突。例如,当用户在 Flutter 视图上拖动时,UIScrollView 可能将其误认为是滚动操作,从而导致 Flutter 视图无法响应拖动手势。
解决手势冲突的最有效方法之一是使用 Flutter 的手势竞技场。这是一个负责协调手势的系统,可确保同一时间只有一个手势被识别。要使用手势竞技场,需要在 Flutter 视图的最顶层添加一个 GestureRecognizer Widget,并将其作为竞技场的子控件。
手势竞技场的使用
以下是使用手势竞技场的示例代码:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import 'package:flutter/gestures.dart'; class MyGestureRecognizer extends GestureRecognizer { // 实现手势识别器的方法 } class MyFlutterView extends StatelessWidget { @override Widget build(BuildContext context) { return RawGestureDetector( gestures: { MyGestureRecognizer(): GestureRecognizerFactoryWithHandlers<MyGestureRecognizer>( () => MyGestureRecognizer(), (MyGestureRecognizer instance) => instance.addPointer(event), (MyGestureRecognizer instance) => instance.handleEvent(event), ), }, child: // Flutter 视图的内容 ); } } |
通过使用这种方法,Flutter 视图的手势和 UIScrollView 的手势可以同时响应,互不干扰,从而完美解决 iOS 上的 Flutter 页面手势冲突问题。
1. 为什么手势冲突在 iOS 上很常见?
因为 iOS 上的 UIScrollView 可以响应多种手势,并且它经常与 Flutter 视图一起使用,这可能导致手势冲突。
2. 手势竞技场是如何工作的?
手势竞技场是一个负责协调手势的系统,可确保同一时间只有一个手势被识别。它通过让手势识别器注册自己并协调它们的活动来实现此目的。
3. 如何在 Flutter 中使用手势竞技场?
在 Flutter 视图的最顶层添加一个 GestureRecognizer Widget,并将其作为竞技场的子控件。
4. 除了手势竞技场,还有其他解决手势冲突的方法吗?
是的,还有其他方法,例如使用 GestureDetector Widget 或重写控件的手势识别逻辑。
5. 为什么解决手势冲突很重要?
解决手势冲突很重要,因为它可以防止错误的操作,并确保用户获得流畅、无缝的体验。
想象一下:你编写出的迷人表单页面

你把它发给产品经理,他看了一眼说:“我一定要完整的输入国家名称吗,当我输入文字时难道你就不能给我展示些建议吗?”,你想了想:“好吧,他是对的”,因此,你决定开发一个‘自动补全‘的’预先输入’功能,随便你怎么称呼它:一个文本展示框 TextField ,当用户输入文字的时候展示一些建议选项。开始工作了..你知道怎么拿到建议数据,你知道怎么写逻辑,你知道所有要做的事情..除了不知道怎么将建议选项浮动展示在 Widgets 之上。

你想了想:打算重新设计代码结构,为了实现悬浮效果,决定将整个页面包装进一个 Stack 组件中,你需要准确的计算每个 Widget 显示的位置,非常侵入性、必须要严谨、容易出错,并且直觉告诉你这么做可能是错误的,有其他的实现方式吗?
方案就是:你可以使用 Flutter 已经提供好的 Stack :Overlay
在这片文章中,我将会介绍如何使用 Overlay ,来创建悬浮在其他 Widget 之上的 Widgets,并且并不需要重构你的整个页面。
你可以使用 Overlay 来展示自动匹配的建议选项,小提示,或着基本上所有的浮动的东西。
官方文档这样定义
A Stack of entries that can be managed independently. // 一个可以独立管理的Stack子类
Overlays let independent child widgets “float” visual elements on top of other widgets by inserting them into the overlay’s Stack. // 通过将可独立管理的子节点widgets加入到
overlay的栈中,Overlays可以将这些widgets浮动展示到显现的elements节点的顶部
这看起来就是我们正在寻找的内容,当我们创建 MaterialApp 的时候,它会自动创建一个Navigator,Navigator 则又会创建一个 Overlay : 一个 Navigator 用来管理所展示的 Views 视图的 Stack 组件。
接下来,让我们一起看看怎么使用 Overlay 来解决我们的问题吧。
注意:这篇文章的核心是介绍如何显示浮动 Widgets,因此不会涉及太多如何实现自动补全文本输入框 TextField 的细节,如果你对一个编写良好、可高度自定义的自动补全 Widget 感兴趣的话,可以参考 flutter_typeahead 。
我们以最初的代码开始吧:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
Scaffold( body: Padding( padding: const EdgeInsets.all(50.0), child: Form( child: ListView( children: <Widget>[ TextFormField( decoration: InputDecoration( labelText: 'Address' ), ), SizedBox(height: 16.0,), TextFormField( decoration: InputDecoration( labelText: 'City' ), ), SizedBox(height: 16.0,), TextFormField( decoration: InputDecoration( labelText: 'Address' ), ), SizedBox(height: 16.0,), RaisedButton( child: Text('SUBMIT'), onPressed: () { // submit the form }, ) ], ), ), ), ) |
*一个简单页面,包含了三个文本输入框:country、city、address。
然后我们就以 country 文本输入框为例吧,将它封装成一个我们自己的 StatefulWidget,命名为 CountriesField 。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class CountriesField extends StatefulWidget { @override _CountriesFieldState createState() => _CountriesFieldState(); } class _CountriesFieldState extends State<CountriesField> { @override Widget build(BuildContext context) { return TextFormField( decoration: InputDecoration( labelText: 'Country' ), ); } } |
接下来我们将要做的是,每次当选中文本输入框获取焦点 Focus 的时候,将一个浮动的 List 列表展示出来。当失去焦点 Focus 的时候,再将它隐藏起来,当然你可以按照自己需求来决定如何实现,你可能需要在用户输入了一些文字后展示它,或者当用户点击 Enter 按钮的时候再隐藏。无论怎样,让我们先看看如何展示这个悬浮的 Widget吧:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
class CountriesField extends StatefulWidget { @override _CountriesFieldState createState() => _CountriesFieldState(); } class _CountriesFieldState extends State<CountriesField> { final FocusNode _focusNode = FocusNode(); OverlayEntry _overlayEntry; @override void initState() { _focusNode.addListener(() { if (_focusNode.hasFocus) { this._overlayEntry = this._createOverlayEntry(); Overlay.of(context).insert(this._overlayEntry); } else { this._overlayEntry.remove(); } }); } OverlayEntry _createOverlayEntry() { RenderBox renderBox = context.findRenderObject(); var size = renderBox.size; var offset = renderBox.localToGlobal(Offset.zero); return OverlayEntry( builder: (context) => Positioned( left: offset.dx, top: offset.dy + size.height + 5.0, width: size.width, child: Material( elevation: 4.0, child: ListView( padding: EdgeInsets.zero, shrinkWrap: true, children: <Widget>[ ListTile( title: Text('Syria'), ), ListTile( title: Text('Lebanon'), ) ], ), ), ) ); } @override Widget build(BuildContext context) { return TextFormField( focusNode: this._focusNode, decoration: InputDecoration( labelText: 'Country' ), ); } } |
以上,我们的建议选择项可以浮在所有 Widget 之上了!
在我们离开之前,让我们在多学喜一点吧!假如我们的页面是可以滚动,我们可能注意到如下现象:

建议选择列表固定在了屏幕上!在某些场景下你可能的确需要固定的,但是在当前场景中,我们不想要它固定,我们想要它跟随我们的 TextField 一起滚动!
关键词滚动,Flutter给我们提供了两个widget:CompositedTransformFollower、CompositedTransformTarget,简单介绍就是,如果我们关联起一个 follower 和一个 target ,那么无论 target 滚动到哪里,这个 follower 将跟随它一起滚动!为了关联起一个 follower 和一个target,我们需要给他们设置相同的LayerLink.
因此我们需要将建议选择列表用 CompositedTransformFollower 包起来,将 TextField 用 CompositedTransformTarget 包起来。然后我们将他们使用想用的 LayerLink 关联起来,这样就可以是建议选择列表跟随 TextField 一起滑动了:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
class CountriesField extends StatefulWidget { @override _CountriesFieldState createState() => _CountriesFieldState(); } class _CountriesFieldState extends State<CountriesField> { final FocusNode _focusNode = FocusNode(); OverlayEntry _overlayEntry; final LayerLink _layerLink = LayerLink(); @override void initState() { _focusNode.addListener(() { if (_focusNode.hasFocus) { this._overlayEntry = this._createOverlayEntry(); Overlay.of(context).insert(this._overlayEntry); } else { this._overlayEntry.remove(); } }); } OverlayEntry _createOverlayEntry() { RenderBox renderBox = context.findRenderObject(); var size = renderBox.size; return OverlayEntry( builder: (context) => Positioned( width: size.width, child: CompositedTransformFollower( link: this._layerLink, showWhenUnlinked: false, offset: Offset(0.0, size.height + 5.0), child: Material( elevation: 4.0, child: ListView( padding: EdgeInsets.zero, shrinkWrap: true, children: <Widget>[ ListTile( title: Text('Syria'), onTap: () { print('Syria Tapped'); }, ), ListTile( title: Text('Lebanon'), onTap: () { print('Lebanon Tapped'); }, ) ], ), ), ), ) ); } @override Widget build(BuildContext context) { return CompositedTransformTarget( link: this._layerLink, child: TextFormField( focusNode: this._focusNode, decoration: InputDecoration( labelText: 'Country' ), ), ); } } |
经过这些操作,我们的 OverlayEntry 现在可以跟随我们的 TextField 一起滚动啦!

重要提示:CompositedTransformFollower 仍然有一点问题,当 target 不可见时,即使 follower 已经从屏幕上隐藏了,这个 follower 仍然会响应点击事件,我已经给 Flutter Team 提了 issue ,此 issue 已经关闭,标记为解决。
Overlay 是一个强大的 Widget ,它给我们提供了一个简单易用的的用来展示浮动 Widget 的的 Stack 组件。
侧滑菜单可以从左面滑出,也可以从右面滑出。在Scaffold中有drawer和endDrawer两个参数,分别对应左边的菜单和右边的菜单。
|
1 2 3 4 5 6 7 |
drawer: new Drawer( child: new ListView( children: <Widget>[ ], ), ), |

|
1 2 3 4 5 6 7 |
endDrawer: new Drawer( child: new ListView( children: <Widget>[ ], ), ), |

其实edge-to-edge全面屏体验并不是什么全新的功能,早在Android 15之前就已经支持了。
但是这个功能推出了很多年,仍然有大量的应用程序没有对全面屏体验进行适配。所以,在这次的Android 15更新中,Google终于下决心要强推这个功能,以让所有应用程序都能达到更好的体验。
需要说明的是,只有将App的targetSdkVersion指定到35或更高时,Android 15才会强制启用edge-to-edge功能。所以,如果你就是不想适配,那么只要不升级targetSdkVersion的版本就行了。
下面开始进入正题,首先跟大家介绍一下到底什么是edge-to-edge全面屏体验。
其实简单来讲,就是让App的界面延伸到手机屏幕的全部空间,这样可以带来更加沉浸式的用户体验。
事实上,绝大多数的App都没有将界面延伸到手机屏幕的全部空间,因此它们本可以提供更好的用户体验。
这里说的手机屏幕的全部空间具体指的是什么呢?我们看下面这张图就能快速了解了。
通过 WidgetsBindingObserver 实现监听软键盘的弹出关闭
实现 WidgetsBindingObserver
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
class _State extends State<MyPage> with WidgetsBindingObserver { @override void initState() { super.initState(); // 初始化 WidgetsBinding.instance.addObserver(this); } @override void dispose() { // 销毁 WidgetsBinding.instance.removeObserver(this); super.dispose(); } // 监听 @override void didChangeMetrics() { super.didChangeMetrics(); WidgetsBinding.instance.addPostFrameCallback((_) { if(mounted){ if(0 == MediaQuery.viewInsetsOf(context).bottom) { // 关闭键盘 } else { // 显示键盘 } } }); } } |
如果使用 Scaffold 作为父组件,在使用 MediaQuery.viewInsetsOf(context).bottom 获取键盘高度如果无论如何都是 0 。
此时有两种方案:
1. 需设置父级 Scaffold 的 resizeToAvoidBottomInset 为 false 。
|
1 2 3 4 5 6 7 8 |
Scaffold( // 子组件若需要监听键盘高度,需设置为false resizeToAvoidBottomInset:false, appBar: AppBar( title: '页面title', ), body: ContentPage(),//子页面中监听键盘高度 ), |
但是这样设置之后,会导致键盘弹出的时候,Scaffold 不会自动向上移动,导致输入范围被遮挡。
2. 使用 Scaffold 上层/同层的 BuildContext 作为参数传递给子 View ,然后传递给 MediaQuery 作为参数。