版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
随着 Flutter 3.3 正式版发布,Global Selection 终于有了官方的正式支持,该功能补全了 Flutter 长时间存在 Selection 异常等问题,特别是在 Flutter Web 下经常会有选择文本时与预期的行为不匹配的情况。
使用
使用SelectionArea也十分简单,如下代码所示,只需要在你想要支持的地方添加SelectionArea即可,甚至可以在每个路由下的Scaffold添加SelectionArea来全面启用支持。
默认情况下SelectionArea已经实现了所有常见的功能,并且 Flutter 针对不同平台进行了差异化实现,如下图所示 Android 和 iOS 会有不同的样式效果。
![]() |
![]() |
![]() |
---|
当然,也许这时候你会发现在 iOS 上的 Toolbar 居然没有全选,其实这是因为 iOS 使用了TextSelectionControls默认的canSelectAll判断,这个判断里有一个条件就是需要 selection 的start == end才符合条件。
所以如果你觉得这个判断有问题,完全可以自己override一个自定义的TextSelectionControls,比如在canSelectAll直接return true。
![]() |
![]() |
---|
是的,对于SelectionArea我们可以通过继承TextSelectionControls来自定义:
- 通过buildToolbar自定义弹出的 Toolbar 样式和逻辑,甚至你可以添加一些额外的标签能力,比如 “插入图片”
- 通过buildHandle自定义 Selection Handle 可拖动部分的样式
而在SelectionArea里,不管是 Handle 还是 Toolbar ,都是通过新增Overlay来实现样式,这部分的逻辑主要在SelectionOverlay对象:
![]() |
![]() |
---|
如果你还不了解Overlay,可以简单理解为:默认情况下所有的路由页面都在一个Overlay下,打开一个 Route 就是添加一个OverlayEntry到Overlay里。
所以 Handle 和 Toolbar 都是通过OverlayEntry打开的特殊“路由”控件,拥有新的层级,例如下方右图就是 Toolbar 所在的OverlayEntry。
![]() |
![]() |
---|
另外,对于 Handle 的颜色定义,默认情况下主要来自TextSelectionTheme和Theme 。
例如MaterialTextSelectionControls里,start 和 end 两个 Handle 的颜色,默认是通过TextSelectionTheme的selectionHandleColor或者Theme的primary来设置。
那文字的选中区域的颜色是怎么来的?难道也是OverlayEntry吗?
答案是否定的,这部分颜色主要是来自于文本绘制时 Canvas 的渲染。
如下代码所示,当文本被绘制时,会判断当前是否有被选中的片段,如果存在选中的片段,会调用绘制对应的选中图层。
而对于文字的选中区块的颜色,默认是通过DefaultSelectionStyle的selectionColor来显示,当然,如下右图所示,在MaterialApp里它依然和TextSelectionTheme的selectionColor或者Theme的primary有关系。
![]() |
![]() |
---|
那如果你还想要在SelectionArea下的某些内容不允许被选中呢?
这里 Flutter 提供了SelectionContainer.disabled实现,只要在对应内容嵌套SelectionContainer.disabled,那么这部分内容下的文本就无法被选中。
![]() |
![]() |
---|
为什么嵌套SelectionContainer.disabled就可以禁用文本选中的能力?这其实和SelectionArea的实现有关系:
SelectionContainer内部实现了一个InheritedWidget,它会往下共享一个SelectionRegistrar,而默认情况下SelectionArea内部使用了SelectionContainer并且往下共享了对应的 Registrar 实现。
- SelectionArea内部的SelectionContainer是有对应的registrar实现往下共享
- SelectionContainer.disabled内部的registrar是null
所以根本区别就在于SelectionContainer.disabled里没有registrar ,如下左图所示,加了 disabled 后获取到的registrar是 null ,那么如下右侧代码所示,在后续可选中区域的更新逻辑中就会直接 return 。
![]() |
![]() |
---|
到这里你应该大致理解了如何使用和自定义一些SelectionArea的能力,那么接下来介绍两个 “Bug” ,通过这两个 “Bug” 我们深入理解SelectionArea内部的实现情况 。
问题1
如下代码所示,当使用了WidgetSpan之后,默认情况下,用户在开始位置拖拽 Handle 进行选择时会无法选中WidgetSpan里的文本。
![]() |
![]() |
---|
PS:其实拖动可以选中,只是这里暂时以不能选中的情况下作为切入点。
为什么会这样?首先要知道,上面代码在使用了WidgetSpan包裹Hello World之后,其实是存在两个Text,也就是上述的 UI 是由两个RenderParagraph绘制完成。
那么对于最外层的Text,其实它的文本内容是“Flutter is the best!”,注意这段文本,其实文本里此时是多了两个空格。
之所以会有这两个空格,其实是因为WidgetSpan使用了0xFFFC的占位符,这段占位符在渲染时,就会被替换为WidgetSpan对应的Hello World和猫头图片。
那么这时候如果我们选择复制,复制出来的内容会是Flutter isthe best! ,中间的两个占位符是不会复制出来,因为在获取可选择片段时,会把对应的placeholderCodeUnit剔除。
另外,当我们点击复制的时候,WidgetSpan所在的Hello World并没有被选中,所以此时调用getSelectedContent就会得到 null ,也就是没有内容。
所以可以看到:此时在手动拖拽选择时,WidgetSpan里的文本是不会被选中,因为它处于不同的Text,对于外层Text而言它只是个占位符。
当然,其实在拖动 Handle 还是可以选中WidgetSpan里的文本,比如你从Hello World开始拖动,这里拖动选中不了的原因后面会解释。
问题 2
如果当我们点击了全选会怎么样?如下图所示,在我们点击全选之后,可以看到两个“奇怪”的问题:
- WidgetSpan里的Hello World可以被选中了
- 左侧的 Start Handle 位置不是在文本开头,而是在WidgetSpan开始
我们首先看第一点,为什么点击全选时,WidgetSpan里的Hello World可以被选中?
其实全选操作和拖拽 Handle 最大的不同就是:它是往下直接发出全选事件SelectAllSelectionEvent,而该事件会触发所有 child 响应事件,自然也就包括了WidgetSpan里的Hello World。
最后负责响应 SelectAll 事件的对象是_SelectableFragment,这里主要有两个关键逻辑:
- _handleSelectAll获取得到_textSelectionStart和_textSelectionEnd,表明此时控件已经被选中
- didChangeSelection里通过paragraph.markNeedsPaint()触发重绘,然后增加选中时的覆盖颜色
可以看到,由于此时WidgetSpan里的Hello World也直接响应了全选事件,所以它会处于选中状态,这样之后在getSelectedContent调用里也可以获取到内容,也就是能够Hello World能被复制出来。
**但是此时复制出来的内容会是Hello World!Flutter isthe best!** ,是不是感觉还不对?这就是我们要说的第二个问题,左侧的 Start Handle 位置不是在文本开头。
首先我们看,为什么复制出来之后的内容会是Hello World!Flutter isthe best!?
正如前面说到的,复制调用的是getSelectedContent方法,如下代码所示,可以看到在selectables这个List的第一位就是Hello World,所以最终拼接出来的文本会是Hello World!Flutter isthe best! 。
那为什么Hello World会排在selectables的第一位? 这就需要讲到 Flutter 里对 Selectable 的一个排序逻辑。
我们知道Text内部是通过RenderParagraph实现文本绘制,而RenderParagraph在初始化的时候,如果存在_registrar,也就是存在SelectionArea的时候,就会通过add把支持选中的片段添加SelectionArea内部的_additions 里。
之后SelectionArea内部会对可选中的内容进行排序,如下代码所示,在sort之前,此时的Hello World在_additions列表的最末端,因为它处于WidgetSpan的 child 里,所以是最晚被加入到_additions的。
而在执行完sort之后 ,可以看到此时Hello World跑到了列表的最前面,这也是为什么复制出来的内容顺序是Hello World开头,然后 Start Handle 会显示在Hello World的原因。
sort的逻辑主要是通过compareOrder实现,简单分析compareOrder的排序实现,可以看到其中有一个_compareVertically的逻辑,通过调试对比,可以看到此时因为Hello World所处的Rect(top)比其他文本高,所以它被认为是更高优先级的位置,类似于被误认为是上一行的情况。
知道了问题那就很好处理了,如下代码所示,如果此时调整一下WidgetSpan的高度,可以看到全选逻辑下 Start Handle 正常了,但是… End Handle 位置又不对了。
![]() |
![]() |
---|
此时复制出来的内容会是Flutter isthe best!Hello World!,因为这个时候会有一个很“微妙”的偏差值,导致Hello World排序时被排列到最后面,从而导致 End Handle 不是预期的位置。
另外,这时候你会发现,如下左侧动图所示,此时拖动 Handle 是可以选中WidgetSpan里的Hello World ,其实之前的情况下也可以,不过需要如右侧动图所示,需要从Hello World开始拖动,因为最开始的情况下selectables里Hello World的排序层级更高,所以如果想要拖动选中,也需要从它开始。
![]() |
![]() |
---|
目前这个问题在 master 和 stable 分支均可以复现,对应 issue 我也提交在 #111021 。
最后
虽然SelectionArea的出现补全了 Flutter 的长久以来的短板之一,不过基于SelectionArea实现的复杂程度,目前SelectionArea还有不少的细节需要优化,但是万事开头难,本次 3.3SelectionArea的落地也算是一个不错的开始。