现在很多网站、应用平台在登录的时候,都支持直接通过微信扫码登录。
最近我发现一个现象:以前需要扫二维码才能登录,而现在,如果你的电脑上已经运行了微信,它能直接检测到,然后点击一个按钮就可以实现登录了。
现在很多网站、应用平台在登录的时候,都支持直接通过微信扫码登录。
最近我发现一个现象:以前需要扫二维码才能登录,而现在,如果你的电脑上已经运行了微信,它能直接检测到,然后点击一个按钮就可以实现登录了。
生命周期是一个 widget 组件加载到卸载的整个周期,熟悉生命周期可以让我们在合适的时机做该做的事情。
在 Flutter 开发中,everything is widget,但我们一般都不用直接继承 Widget 类来实现一个新组件,我们通常会通过继承 StatelessWidget 或 StatefulWidget 来间接继承 Widget 类来实现。StatelessWidget 和StatefulWidget 都是直接继承自 Widget 类,而这两个类也正是 Flutter 中非常重要的两个抽象类,它们引入了两种 Widget 模型。此文主要介绍这两种 widget 的生命周期。
StatelessWidget 是无状态的 Widget,一旦创建就不会发生变化,所以无法提供 setState 修改组件的状态,它内部属性应声明为 final,防止意外发生改变。所以 StatelessWidget 的生命周期只有一个,就是 build,build 是用来创建 Widget 的,但因为 build 在每次界面刷新的时候都会调用,所以不要在 build 里写业务逻辑,可以把业务逻辑写到你的 StatelessWidget 的构造函数里。其生命周期如下图:
在 Flutter 应用开发中,手势处理是构建交互式界面的核心环节。然而当多个手势识别器或可滚动组件嵌套使用时,经常会出现手势冲突问题。
本文将深入探讨 Flutter 中解决手势冲突的各种方法,并分析其适用场景,帮助您掌握高效的手势管理策略。
在理解如何解决手势冲突之前,我们需要先了解 Flutter 中手势系统的基本原理 :
Flutter 的手势识别基于 GestureRecognizer 竞争机制。当用户触发指针事件(PointerDown)时,多个手势识别器会进入 gesture arena 竞争,最终只有一个胜出。竞争的过程主要有两个阶段:
开发过程中比较常见的手势冲突场景包括:
针对不同的应用场景,Flutter 提供了不同的冲突的处理方式
通过控制 GestureDetector 的 behavior 参数控制手势事件如何在Widget 树中传递。常见的 behavior 值有:
有兴趣的话,可以尝试改变内外两个 GestureDetector 的 behavior 的值来加深理解对这个处理方式的理解
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
GestureDetector( behavior: HitTestBehavior.translucent, // 允许手势穿透 onTap: () => print('Outer tapped'), child: Container( width: 200, height: 200, color: Colors.blue, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => print('Inner tapped'), child: Container( width: 100, height: 100, color: Colors.red, ), ), ), ); |
适用场景
这两个组件在使用时,会完成拦截或忽略掉所有的手势:
适用场景:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 阻止事件继续传递 AbsorbPointer( child: GestureDetector( onTap: () => print('This will absorb all taps'), child: Container(color: Colors.red), ), ) // 自身和子组件完全忽略事件 IgnorePointer( child: GestureDetector( onTap: () => print('This will never be called'), child: Container(color: Colors.green), ), ) |
Flutter 的手势竞技场( GestureArena )机制允许自定义手势识别的竞争规则,通过使用底层的 RawGestureDetector 注册多个手势识别器,由手势竞技场决定胜出者,整个竞技的过程会经历
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
RawGestureDetector( gestures: { AllowMultipleGestureRecognizer: GestureRecognizerFactoryWithHandlers<AllowMultipleGestureRecognizer>( () => AllowMultipleGestureRecognizer(), (instance) => instance..onTap = () => print('Tapped'), ), LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( () => LongPressGestureRecognizer(), (instance) => instance..onLongPress = () => print('Long Press'), ), }, child: Container(color: Colors.amber), ) |
适用场景
直接通过 Listener 去处理最原始的指针事件,自由控制:
|
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 |
Listener( onPointerDown: (event) { print('Pointer down at ${event.position}'); // 可阻止事件传播 // event.stopPropagation(); }, onPointerMove: (event) => print('Pointer move'), onPointerUp: (event) => print('Pointer up'), // 还有其他的指针事件 child: Container(color: Colors.purple), ) // 里面的指针事件,可按需使用 // const Listener({ // super.key, // this.onPointerDown, // this.onPointerMove, // this.onPointerUp, // this.onPointerHover, // this.onPointerCancel, // this.onPointerPanZoomStart, // this.onPointerPanZoomUpdate, // this.onPointerPanZoomEnd, // this.onPointerSignal, // this.behavior = HitTestBehavior.deferToChild, // super.child, |
适用场景
通过继承 ScrollPhysics 类,可以自定义滚动行为,控制滚动事件的传递和处理。
|
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 |
class CustomScrollPhysics extends ScrollPhysics { const CustomScrollPhysics({super.parent}); @override CustomScrollPhysics applyTo(ScrollPhysics? ancestor) { return CustomScrollPhysics(parent: buildParent(ancestor)); } @override bool shouldAcceptUserOffset(ScrollMetrics position) { // 自定义逻辑:决定是否接受用户滚动 return super.shouldAcceptUserOffset(position); } @override Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { if (velocity == 0.0 || position.minScrollExtent == position.maxScrollExtent) { return null; } final spring = SpringDescription.withDampingRatio( mass: 1.0, stiffness: 200.0, ratio: 1.0, ); // 判断是否滚动到最底下了 if (velocity > 0 && position.pixels >= position.maxScrollExtent) { return ScrollSpringSimulation( spring: spring, start: position.pixels, end: position.maxScrollExtent + 100, velocity: velocity, tolerance: Tolerance.defaultTolerance, ); // 判断是否滚动到最顶了 } else if (velocity < 0 && position.pixels <= position.minScrollExtent) { return ScrollSpringSimulation( spring: spring, start: position.pixels, end: position.minScrollExtent - 100, velocity: velocity, tolerance: Tolerance.defaultTolerance, ); } return super.createBallisticSimulation(position, velocity); } } // 使用自定义 ScrollPhysics ListView( physics: CustomScrollPhysics(), children: [...], ); |
适用场景
通过监听滚动通知(如 ScrollNotification),可以在父组件中捕获并处理滚动事件,从而控制子组件的行为。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
NotificationListener<ScrollNotification>( onNotification: (notification) { if (notification is ScrollStartNotification) { print('Scroll started'); } else if (notification is ScrollEndNotification) { print('Scroll ended'); } // 返回 true 会拦截通知,阻止传递给子组件 return false; }, child: ListView( children: [...], ), ); |
适用场景
项目的某一个场景中,会有一个 PageView 里面嵌套 ScrollView 的场景,而且这两个的滚动方向是一致的(都是竖向的滚动)。
这种情况下要怎样保证当 ScrollView 滑动到最底(最顶)时,能触发 PageView的 翻页呢?结合前面的介绍的处理手势冲突的几种方式,大家会选择哪个呢?
最开始笔者是选择了自定义 ScrollPhysics 的方式来尝试处理的,但发现最终也只能有一个组件能滚动(中间尝试不同解决方案的痛苦就一一细说了),这里最终是通过NotificationListener 来协调这两者的滚动的。(这个方案不一定是最好的)
|
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 |
PageView.builder( itemCount: dataLength, controller: _pageController, scrollDirection: Axis.vertical, onPageChanged: (int index) { _curPageIndex.value = index; }, itemBuilder: (BuildContext context, int index) { return NotificationListener<ScrollNotification>( onNotification: (ScrollNotification scroll) { // 通过判断 if (scroll is ScrollEndNotification) { final pixels = scroll.metrics.pixels; final maxScroll = scroll.metrics.maxScrollExtent; if (pixels == maxScroll) { // 滚动到底部,触发翻页 _pageController.nextPage( duration: Duration(milliseconds: 300), curve: Curves.easeInOut, ); } else if (pixels == 0.0) { // 滚动到顶部,触发上一页 _pageController.previousPage( duration: Duration(milliseconds: 300), curve: Curves.easeInOut, ); } } return false; }, child: SingleChildScrollView(child:Column(....)) ); } |
本文主要介绍了 Flutter 中手势冲突几种解决方案,从简单高效的 AbsorbPointer 到底层强大的 Listener,可以应对不同复杂度的交互场景。
可以根据不同的业务场景选择不同的解决方案,这里给几点在选择解决方案时可参考的点:
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 作为参数。