Flutter手势冲突难题怎么破?几种解决方式大揭秘!

Flutter 应用开发中,手势处理是构建交互式界面的核心环节。然而当多个手势识别器或可滚动组件嵌套使用时,经常会出现手势冲突问题。

本文将深入探讨 Flutter 中解决手势冲突的各种方法,并分析其适用场景,帮助您掌握高效的手势管理策略。

1 手势冲突的根源

在理解如何解决手势冲突之前,我们需要先了解 Flutter 中手势系统的基本原理 :

Flutter 的手势识别基于 GestureRecognizer 竞争机制。当用户触发指针事件(PointerDown)时,多个手势识别器会进入 gesture arena 竞争,最终只有一个胜出。竞争的过程主要有两个阶段:

  • 命中测试阶段(Hit Test Phase):确定哪些 Widget 接收了手势事件。
  • 手势识别阶段(Gesture Recognition Phase):多个 Widget 可能会识别相同的手势,从而产生冲突。

开发过程中比较常见的手势冲突场景包括:

  • 嵌套滚动组件(如 PageView 中的 ListView
  • 多层手势检测器(如 InkWell 内部的 GestureDetector
  • 父子组件都监听相同类型手势事件
  • 多个手势识别器同时竞争同一区域

2 解决手势冲突的方式

针对不同的应用场景,Flutter 提供了不同的冲突的处理方式

2.1 HitTestBehavior (最轻量级)

通过控制 GestureDetectorbehavior 参数控制手势事件如何在Widget 树中传递。常见的 behavior 值有:

  • HitTestBehavior.translucent : 自身和子组件都能接收事件
  • HitTestBehavior.opaque: 拦截所有事件(即使透明区域)
  • HitTestBehavior.deferToChild: 默认值,优先传给子组件

有兴趣的话,可以尝试改变内外两个 GestureDetectorbehavior 的值来加深理解对这个处理方式的理解

适用场景

  • 父子组件都需要响应手势
  • 需要控制手势事件的传递层次(通过 behavior )
  • 简单按钮嵌套
2.2 AbsorbPointer/IgnorePointer组件

这两个组件在使用时,会完成拦截或忽略掉所有的手势:

  • IgnorePointer:使子 Widget 忽略所有手势事件,但仍会参与布局和绘制;
  • AbsorbPointer:拦截并消耗所有手势事件,子 Widget 无法接收手势,自身可以接收并处理事件。

适用场景

  • 临时禁用某个区域的手势
  • 阻止下层 Widget 接收手势事件;
  • 复杂UI中动态切换交互状态;

2.3 RawGestureDetector与手势竞技场

Flutter 的手势竞技场( GestureArena )机制允许自定义手势识别的竞争规则,通过使用底层的 RawGestureDetector 注册多个手势识别器,由手势竞技场决定胜出者,整个竞技的过程会经历

  1. 当指针按下时,所有识别器进入竞技场
  2. 通过 addPointer() 处理事件流
  3. 识别器声明是否"准备好"处理事件
  4. 竞技场选择获胜者(首个声明准备就绪的识别器)
  5. 胜出者接收后续事件,其他被拒绝

适用场景

  • 需要同时识别多种手势
  • 复杂手势组合(缩放+旋转+平移)
  • 自定义手势识别逻辑
2.4 Listener组件处理原始指针事件

直接通过 Listener 去处理最原始的指针事件,自由控制:

适用场景

  • 需要底层事件控制的场景
  • 高度定制化的交互需求
  • 性能关键型的手势处理
2.5 自定义 ScrollPhysics

通过继承 ScrollPhysics 类,可以自定义滚动行为,控制滚动事件的传递和处理。

适用场景

  • 嵌套滚动组件(如 PageViewListView 的冲突)
  • 需要精确控制滚动阈值和边界条件
2.6 使用 NotificationListener

通过监听滚动通知(如 ScrollNotification),可以在父组件中捕获并处理滚动事件,从而控制子组件的行为。

适用场景

  • 监听滚动状态并作出响应
  • 协调多层级滚动组件的行为

3 手势冲突的例子

项目的某一个场景中,会有一个 PageView 里面嵌套 ScrollView 的场景,而且这两个的滚动方向是一致的(都是竖向的滚动)。

这种情况下要怎样保证当 ScrollView 滑动到最底(最顶)时,能触发 PageView的 翻页呢?结合前面的介绍的处理手势冲突的几种方式,大家会选择哪个呢?

最开始笔者是选择了自定义 ScrollPhysics 的方式来尝试处理的,但发现最终也只能有一个组件能滚动(中间尝试不同解决方案的痛苦就一一细说了),这里最终是通过NotificationListener 来协调这两者的滚动的。(这个方案不一定是最好的)

4 总结

本文主要介绍了 Flutter 中手势冲突几种解决方案,从简单高效的 AbsorbPointer 到底层强大的 Listener,可以应对不同复杂度的交互场景。

可以根据不同的业务场景选择不同的解决方案,这里给几点在选择解决方案时可参考的点:

  • 准确识别冲突来源和类型
  • 评估交互的复杂程度
  • 考虑性能和维护成本

参考链接


Flutter手势冲突难题怎么破?几种解决方式大揭秘!

OpenBB的介绍以及如何使用OpenBB助力A股港股的金融数据分析

OpenBB 是一个开源的金融数据平台,旨在为投资者、分析师、研究人员和开发者提供免费、透明且易于使用的金融与宏观经济数据访问接口。它曾被认为是类似于彭博终端(Bloomberg Terminal)的功能性替代品,但完全开放源码,用户可以自由定制和扩展。

在一些文章中将 OpenBB 解释为 Open Bloomberg,这是个误解。尽管它常被视为“开源版彭博终端”,但其名称中的“BB”实际上源自黑莓公司的股票代码,而 OpenBB 的创始人此前曾在黑莓股票上亏损。

继续阅读OpenBB的介绍以及如何使用OpenBB助力A股港股的金融数据分析

Flutter手势冲突与手势竞争

手势冲突的原因

当多个手势同时出现在同一控件上时,就会发生手势冲突。这可能会导致系统无法准确识别用户的意图,从而导致错误的操作。iOS 上的 UIScrollView 是手势冲突的主要来源,因为它可以响应滚动、缩放和点击等各种手势。当 Flutter 视图与 UIScrollView 同时存在时,很容易发生手势冲突。

常见的冲突场景

最常见的场景是将 Flutter 视图嵌套在 UIScrollView 中。在这种情况下,UIScrollView 的滚动手势和 Flutter 视图的手势很容易发生冲突。例如,当用户在 Flutter 视图上拖动时,UIScrollView 可能将其误认为是滚动操作,从而导致 Flutter 视图无法响应拖动手势。

解决方案:手势竞技场

解决手势冲突的最有效方法之一是使用 Flutter 的手势竞技场。这是一个负责协调手势的系统,可确保同一时间只有一个手势被识别。要使用手势竞技场,需要在 Flutter 视图的最顶层添加一个 GestureRecognizer Widget,并将其作为竞技场的子控件。

手势竞技场的使用

以下是使用手势竞技场的示例代码:

通过使用这种方法,Flutter 视图的手势和 UIScrollView 的手势可以同时响应,互不干扰,从而完美解决 iOS 上的 Flutter 页面手势冲突问题。

常见问题解答

1. 为什么手势冲突在 iOS 上很常见?

因为 iOS 上的 UIScrollView 可以响应多种手势,并且它经常与 Flutter 视图一起使用,这可能导致手势冲突。

2. 手势竞技场是如何工作的?

手势竞技场是一个负责协调手势的系统,可确保同一时间只有一个手势被识别。它通过让手势识别器注册自己并协调它们的活动来实现此目的。

3. 如何在 Flutter 中使用手势竞技场?

在 Flutter 视图的最顶层添加一个 GestureRecognizer Widget,并将其作为竞技场的子控件。

4. 除了手势竞技场,还有其他解决手势冲突的方法吗?

是的,还有其他方法,例如使用 GestureDetector Widget 或重写控件的手势识别逻辑。

5. 为什么解决手势冲突很重要?

解决手势冲突很重要,因为它可以防止错误的操作,并确保用户获得流畅、无缝的体验。

参考链接


Flutter:使用Overlay展示浮动的Widget

想象一下:你编写出的迷人表单页面

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

你想了想:打算重新设计代码结构,为了实现悬浮效果,决定将整个页面包装进一个 Stack 组件中,你需要准确的计算每个 Widget 显示的位置,非常侵入性、必须要严谨、容易出错,并且直觉告诉你这么做可能是错误的,有其他的实现方式吗?

方案就是:你可以使用 Flutter 已经提供好的 StackOverlay

在这片文章中,我将会介绍如何使用 Overlay ,来创建悬浮在其他 Widget 之上的 Widgets,并且并不需要重构你的整个页面。

你可以使用 Overlay 来展示自动匹配的建议选项,小提示,或着基本上所有的浮动的东西。

Overlay是什么?

官方文档这样定义

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 的时候,它会自动创建一个NavigatorNavigator 则又会创建一个 Overlay : 一个 Navigator 用来管理所展示的 Views 视图的 Stack 组件。

接下来,让我们一起看看怎么使用 Overlay 来解决我们的问题吧。

注意:这篇文章的核心是介绍如何显示浮动 Widgets,因此不会涉及太多如何实现自动补全文本输入框 TextField 的细节,如果你对一个编写良好、可高度自定义的自动补全 Widget 感兴趣的话,可以参考 flutter_typeahead

初始代码

我们以最初的代码开始吧:

*一个简单页面,包含了三个文本输入框:country、city、address。

然后我们就以 country 文本输入框为例吧,将它封装成一个我们自己的 StatefulWidget,命名为 CountriesField

接下来我们将要做的是,每次当选中文本输入框获取焦点 Focus 的时候,将一个浮动的 List 列表展示出来。当失去焦点 Focus 的时候,再将它隐藏起来,当然你可以按照自己需求来决定如何实现,你可能需要在用户输入了一些文字后展示它,或者当用户点击 Enter 按钮的时候再隐藏。无论怎样,让我们先看看如何展示这个悬浮的 Widget吧:

  • 我们给 TextFormField 绑定了一个 FocusNode,并且在 initState 里面给 FocusNode 添加了一个监听事件,通过监听事件来获取什么时候获得/失去焦点。
  • 每次当我们获取焦点 (_focusNode.hasFocus == true) 的时候, 我们通过 _createOverlayEntry 创建一个OverlayEntry 实例对象,然后通过使用 Overlay.of(context).insert,将它插入到最邻近的 Overlay Widget 中去。
  • 每次当我们失去焦点 (_focusNode.hasFocus == false) 的时候,我们通过使用 _overlayEntry.remove 来移除刚才添加的 Overlay 实例。
  • _createOverlayEntry 通过使用 context.findRenderObject 来获取我们的 Widget 所在的渲染对象 RenderBox ,渲染对象里包含位置 Position、大小 Size、和一些其他关于渲染的信息,有了这些信息能够帮助我们计算在哪里展示我们的悬浮列表。
  • _createOverlayEntry 通过渲染信息来获取当前 Widget 的大小,也可以使用 renderBox.localToGlobal 来获取当前 Widget 在屏幕上的坐标。我们将 localToGlobal 设置为 Offset.zero 这意味着我们将在渲染对象中使用(0,0)坐标,并且将他们转换为屏幕上相对应的坐标。
  • 接着我们创建了 OverlayEntry,这时一个用来将 Widgets 展示到 Overlay 中的 Widget
  • 当前创建的 OverlayEntry 的是一个 Positioned Widget。请牢记 Positioned Widgets 只能被插入到 Stack 中,当然 Overlay 其实也是一个 Stack
  • 我们设置 Positioned Widget 的坐标,给它和 TextField 相同的 X 轴坐标,相同的宽度,相同的 Y 轴坐标,当然为了不遮挡到 TextField,在底部进行了一些偏移。
  • Positioned 内部,我们设置了一个展示建议选项的 ListView(里面默写了例子中的一些国家)。注意到我把所有的内容都包在了 Material 中,关于这样写有两个原因: Overlay 默认不包含 Material Widget,并且很多 Widgets 如果没有有 Material 祖先节点的话不能展示,除此之外 Material 还提供了 elevation 属性,可以让我们给 Widget 设置阴影效果,看起来就像真正浮在上面一样。

以上,我们的建议选择项可以浮在所有 Widget 之上了!

彩蛋:跟随 Widget 滑动!

在我们离开之前,让我们在多学喜一点吧!假如我们的页面是可以滚动,我们可能注意到如下现象:

建议选择列表固定在了屏幕上!在某些场景下你可能的确需要固定的,但是在当前场景中,我们不想要它固定,我们想要它跟随我们的 TextField 一起滚动!

关键词滚动,Flutter给我们提供了两个widget:CompositedTransformFollowerCompositedTransformTarget,简单介绍就是,如果我们关联起一个 follower 和一个 target ,那么无论 target 滚动到哪里,这个 follower 将跟随它一起滚动!为了关联起一个 follower 和一个target,我们需要给他们设置相同的LayerLink.

因此我们需要将建议选择列表用 CompositedTransformFollower 包起来,将 TextFieldCompositedTransformTarget 包起来。然后我们将他们使用想用的 LayerLink 关联起来,这样就可以是建议选择列表跟随 TextField 一起滑动了:

  • 我们将 Material WidgetCompositedTransformFollower 包裹进 OverlayEntry 中,将TextFormField 包裹进 CompositedTransformTarget 中。
  • 我们使用相同的 LayerLink 示例,来关联 followertarget,这样 followertarget 将会在相同的坐标系中,高效的跟随 target 而动。
  • Positioned Widget 中移除了 topleft 属性,因为默认 followertarget 有相同的坐标,因此不在需要。然而我们保留了 width 属性,因为如果不设置的话,follower 将会无限的延伸。
  • 为了不遮挡 TextFormField ,我们给 CompositedTransformFollower 设置了一个offset(和之前一样的原因)。
  • 最后,我们将 showWhenUnlinked 属性设置为 false,当 TextFormField 在屏幕上不可见时,用来隐藏OverlayEntry(比如我们滑动出屏幕底部很远的时候)。

经过这些操作,我们的 OverlayEntry 现在可以跟随我们的 TextField 一起滚动啦!

重要提示:CompositedTransformFollower 仍然有一点问题,当 target 不可见时,即使 follower 已经从屏幕上隐藏了,这个 follower 仍然会响应点击事件,我已经给 Flutter Team 提了 issue ,此 issue 已经关闭,标记为解决。

Overlay 是一个强大的 Widget ,它给我们提供了一个简单易用的的用来展示浮动 Widget 的的 Stack 组件。

参考链接


Flutter - 左右侧滑菜单:drawer和endDrawer

侧滑菜单可以从左面滑出,也可以从右面滑出。在Scaffold中有drawer和endDrawer两个参数,分别对应左边的菜单和右边的菜单。

参考链接


Android 15新特性,强制edge-to-edge全面屏体验

其实edge-to-edge全面屏体验并不是什么全新的功能,早在Android 15之前就已经支持了。

但是这个功能推出了很多年,仍然有大量的应用程序没有对全面屏体验进行适配。所以,在这次的Android 15更新中,Google终于下决心要强推这个功能,以让所有应用程序都能达到更好的体验。

需要说明的是,只有将App的targetSdkVersion指定到35或更高时,Android 15才会强制启用edge-to-edge功能。所以,如果你就是不想适配,那么只要不升级targetSdkVersion的版本就行了。

下面开始进入正题,首先跟大家介绍一下到底什么是edge-to-edge全面屏体验。

其实简单来讲,就是让App的界面延伸到手机屏幕的全部空间,这样可以带来更加沉浸式的用户体验。

事实上,绝大多数的App都没有将界面延伸到手机屏幕的全部空间,因此它们本可以提供更好的用户体验。

这里说的手机屏幕的全部空间具体指的是什么呢?我们看下面这张图就能快速了解了。

继续阅读Android 15新特性,强制edge-to-edge全面屏体验

Flutter监听软键盘的弹出和关闭获取键盘高度

通过 WidgetsBindingObserver 实现监听软键盘的弹出关闭


实现 WidgetsBindingObserver

注意:

如果使用 Scaffold 作为父组件,在使用 MediaQuery.viewInsetsOf(context).bottom 获取键盘高度如果无论如何都是 0

此时有两种方案:

1. 需设置父级 ScaffoldresizeToAvoidBottomInsetfalse

但是这样设置之后,会导致键盘弹出的时候,Scaffold 不会自动向上移动,导致输入范围被遮挡。

2. 使用 Scaffold 上层/同层的 BuildContext 作为参数传递给子 View ,然后传递给 MediaQuery 作为参数。

原始链接


[Powershell] 无需安装软件,通过命令行监控 CPU 温度

我们知道 Windows 电脑没有监控 CPU 温度的功能,如果想知道 CPU 温度需要安装软件,比如鲁大师。

那么是否可以不安装软件,就实现 CPU 的温度监控呢?

以管理员权限执行如下 PowerShell 脚本:

方法一:

方法二:

参考链接


macOS双频蓝牙鼠标失联问题

前置条件

  • MacBook Pro 2023-Apple M2 Pro (4能效核、8性能核、32GB内存、2TB磁盘)
  • macOS Sequoia 15.5 
  • Lenovo ThinkLife 静音鼠标无线蓝牙版 WLM210

问题现象

蓝牙鼠标正常配对使用,刚刚开始使用正常的。但是过一阵子不使用蓝牙鼠标,或者鼠标电源调整成关闭状态,或者拔掉电池,大概率连接不上。需要在电脑上手工删除蓝牙连接,然后重新配对。

问题定位

刚开始猜测是鼠标使用的 南孚 TENAVOLTS  锂电池 DC-DC 降压电路释放的信号干扰到了蓝牙通信协议或者电压纹波导致芯片工作异常,在更换为普通的 1.5V 非充电电池之后,问题依旧复现。

无意中点击蓝牙设备列表,发现重新配对之后 Lenovo ThinkLifeMAC 地址变化了。这说明两者进行配对的时候使用了动态协商出来的临时 MAC 地址,没有使用设备的真实 MAC 地址。这样诱发一个问题,那就是鼠标需要记住这个动态协商出来的 MAC 地址,然后用这个地址进行通信。这样就能解释为什么拔掉电池多等一会儿,让设备完全放电,再插上电池很容易复现这个问题。因为长时间断电之后,设备上记录的协商 MAC 地址丢失了。

继续阅读macOS双频蓝牙鼠标失联问题