说说Flutter中的无名英雄 —— Focus
Posted 唯鹿
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了说说Flutter中的无名英雄 —— Focus相关的知识,希望对你有一定的参考价值。
Focus系列的Widget及功能类在Flutter中可以说是无名英雄的存在,默默的付出但却不太为人所知。在日常开发使用中也不太会用到它,这是为什么呢?带着这个问题我们开始今天的内容。
1.Focus相关介绍
这里大致介绍一些Focus相关Widget及功能类,便于后面理解Focus Tree部分。本篇源码基于1.20.0-2.0.pre。
1.1 FocusNode
FocusNode
是用于Widget获取键盘焦点和处理键盘事件的对象。它是继承自ChangeNotifier
,所以我们可以在任意位置获取对应的FocusNode
信息。
下面说几个FocusNode
常用方法:
-
requestFocus
用作请求焦点,注意这个请求焦点的执行放在了scheduleMicrotask
中,因此结果可能会延迟最多一帧。 -
unfocus
用作取消焦点,默认行为为UnfocusDisposition.scope
:
void unfocus(UnfocusDisposition disposition = UnfocusDisposition.scope,)
....
UnfocusDisposition
枚举类是焦点取消后的行为,分为scope
和previouslyFocusedChild
两种。
-
scope
表示向上寻找最近的FocusScopeNode
。 -
previouslyFocusedChild
是寻找上一个焦点位置,如果没有则给当前FocusScopeNode
。
具体实现可见unfocus
源码,这里就不多说了。
dispose
这个没啥说的,注意使用FocusNode
完后及时销毁。
1.2 FocusScopeNode
FocusScopeNode
是FocusNode
的子类。它将FocusNode
组织到一个作用域中,形成一组可以遍历的节点。它会提供最后一个获取焦点的FocusNode
(focusedChild),如果其中一个节点的焦点被移除,那么此FocusScopeNode
将再次获得焦点,同时_focusedChildren
清空。
/// Returns the child of this node that should receive focus if this scope
/// node receives focus.
///
/// If [hasFocus] is true, then this points to the child of this node that is
/// currently focused.
///
/// Returns null if there is no currently focused child.
FocusNode get focusedChild
return _focusedChildren.isNotEmpty ? _focusedChildren.last : null;
// A stack of the children that have been set as the focusedChild, most recent
// last (which is the top of the stack).
final List<FocusNode> _focusedChildren = <FocusNode>[];
注意这里的_focusedChildren
并不是FocusScopeNode
下出现的所有FocusNode
,而是获取过焦点的FocusNode
才会在里面。源码实现如下:
void _setAsFocusedChildForScope()
FocusNode scopeFocus = this;
for (final FocusScopeNode ancestor in ancestors.whereType<FocusScopeNode>())
// 从聚焦的历史中移除
ancestor._focusedChildren.remove(scopeFocus);
// 再将它添加至最后,这样上面的focusedChild可以获取到最后获取过焦点的节点
ancestor._focusedChildren.add(scopeFocus);
scopeFocus = ancestor;
FocusScopeNode
比较重要的方法是setFirstFocus
,用来设置子作用域节点。
void setFirstFocus(FocusScopeNode scope)
if (scope._parent == null)
// scope没有父节点,将scope添加至当前节点下
_reparent(scope);
if (hasFocus)
// 当前作用域存在焦点,_doRequestFocus将焦点移到scope上,同时记录节点。
scope._doRequestFocus(findFirstFocus: true);
else
// 当前作用域不存在焦点,记录节点。
scope._setAsFocusedChildForScope();
1.3 Focus
Focus
是一个Widget,可以用来分配焦点给它本身及其子Widget。内部管理着一个FocusNode
,监听焦点的变化,来保持焦点层次结构与Widget层次结构同步。
我们常用的InkWell
就使用了它,而Button、 Chip等大量的Widget又使用了InkWell
,所以Focus
可以说是无处不在。
我们来看一下InkResponse
源码:
这里发现了Focus
,我们看看它的onFocusChange
实现:
void _handleFocusUpdate(bool hasFocus)
_hasFocus = hasFocus;
_updateFocusHighlights();
if (widget.onFocusChange != null)
widget.onFocusChange(hasFocus);
有焦点变化时修改_hasFocus
值调用_updateFocusHighlights
方法。
void _updateFocusHighlights()
bool showFocus;
switch (FocusManager.instance.highlightMode)
case FocusHighlightMode.touch:
showFocus = false;
break;
case FocusHighlightMode.traditional:
showFocus = _shouldShowFocus;
break;
updateHighlight(_HighlightType.focus, value: showFocus);
最终调用updateHighlight
方法让WIdget有一个获取焦点时的高亮显示。
这里有个枚举类FocusHighlightMode
,它是表示使用何种交互模式获取的焦点。分为touch
和traditional
。
默认的区分实现如下:
static FocusHighlightMode get _defaultModeForPlatform
switch (defaultTargetPlatform)
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.ios:
if (WidgetsBinding.instance.mouseTracker.mouseIsConnected)
return FocusHighlightMode.traditional;
return FocusHighlightMode.touch;
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
return FocusHighlightMode.traditional;
return null;
移动端在没有鼠标连接的情况下都是touch
,桌面端都为传统的方式(键盘和鼠标)。
所以这也回答我一开始的问题,我们一般只考虑了移动设备,也就是touch
的部分,这部分其实我们不太需要给按钮处理焦点效果,可能类似给Android TV盒子用的这类App才需要。而Flutter提供的Widget需要考虑各个平台效果,所以才使用了这些。类似在上面的InkResponse
源码中,还出现了MouseRegion
这个Widget,它是跟踪鼠标移动的,比如在Web端鼠标移动到按钮上,按钮会有一个变化效果。
1.4 FocusScope
FocusScope
与Focus
类似,不过它的内部管理的是FocusScopeNode
。它不改变主焦点,它只是改变了接收焦点的作用域节点。这个在源码中使用的不多,但却都很重要的位置。
比如Navigator
和Route
,首先Navigator
有一个FocusScope
,自动获取焦点。在它承载的一个个路由上也会添加FocusScope
,这样当页面跳转/Dialog弹框时可以将焦点的作用域移动到上面(通过setFirstFocus
方法)。
类似Drawer
也是一样。当抽屉打开时,我们的焦点作用域就要移动到Drawer
,所以也要使用FocusScope
。
如果我们要管理焦点,在页面中有一个Stack
,上层覆盖了下层Widget导致下面不可操作。这时我们就可以使用FocusScope
将焦点作用域移动至上面。
2.Focus Tree
Flutter里面有按照分类不同存在各种各样的“树”,比如常说的三棵树Widget Tree、Element Tree 和 RenderObject Tree,其他的比如我之前博客说过的Semantics Tree,和这里要介绍的Focus Tree。
Focus Tree是与Widget Tree独立开的、结构相对简单的树,它是维护Widget Tree中可聚焦Widget之间的层次关系。Focus Tree因为无法通过工具来可视化观察,我们可以使用Focus Tree的管理类FocusManager
中的debugDumpFocusTree
方法打印出来。
所以这里我新建一个项目,写一个小例子来看一下。代码很简单,Column
里一个TextField
和FlatButton
。
class _MyHomePageState extends State<MyHomePage>
@override
Widget build(BuildContext context)
return Material(
child: Column(
children: [
TextField(),
FlatButton(
child: Text('打印FocusTree'),
onPressed: ()
WidgetsBinding.instance.addPostFrameCallback((_)
debugDumpFocusTree();
);
,
),
],
),
);
点击按钮,打印结果如下:
FocusManager#4148c
│ UPDATE SCHEDULED
│ primaryFocus: FocusScopeNode#af55c(_ModalScopeState<dynamic>
│ Focus Scope [PRIMARY FOCUS])
│ nextFocus: FocusScopeNode#4f0d5(Navigator Scope [IN FOCUS PATH])
│ primaryFocusCreator: FocusScope ← _ActionsMarker ← Actions ←
│ PageStorage ← Offstage ← _ModalScopeStatus ←
│ _ModalScope<dynamic>-[LabeledGlobalKey<_ModalScopeState<dynamic>>#bfb70]
│ ← _EffectiveTickerMode ← TickerMode ←
│ _OverlayEntryWidget-[LabeledGlobalKey<_OverlayEntryWidgetState>#3fa85]
│ ← _Theatre ← Overlay-[LabeledGlobalKey<OverlayState>#2d724] ←
│ _FocusMarker ← Semantics ← FocusScope ← AbsorbPointer ←
│ _PointerListener ← Listener ← HeroControllerScope ←
│ Navigator-[GlobalObjectKey<NavigatorState>
│ _WidgetsAppState#9404f] ← ⋯
│
└─rootScope: FocusScopeNode#185ad(Root Focus Scope [IN FOCUS PATH])
│ IN FOCUS PATH
│ focusedChildren: FocusScopeNode#4f0d5(Navigator Scope [IN FOCUS
│ PATH])
│
└─Child 1: FocusNode#5bacc(Shortcuts [IN FOCUS PATH])
│ context: Focus
│ NOT FOCUSABLE
│ IN FOCUS PATH
│
└─Child 1: FocusNode#1cd76(FocusTraversalGroup [IN FOCUS PATH])
│ context: Focus
│ NOT FOCUSABLE
│ IN FOCUS PATH
│
└─Child 1: FocusScopeNode#4f0d5(Navigator Scope [IN FOCUS PATH])
│ context: FocusScope
│ IN FOCUS PATH
│
└─Child 1: FocusScopeNode#af55c(_ModalScopeState<dynamic> Focus Scope [PRIMARY FOCUS])
│ context: FocusScope
│ PRIMARY FOCUS
│
├─Child 1: FocusNode#e72e2
│ context: EditableText-[LabeledGlobalKey<EditableTextState>#c2f8a]
│
└─Child 2: FocusNode#0b7c0
context: Focus
我从下往上说一下代表的含义:
-
Child 1: FocusNode#e72e2
和Child 2: FocusNode#0b7c0
一看就是同级,代表的就是TextField
和FlatButton
。 -
上一层
FocusScopeNode#af55c
是当前的页面,可以看到焦点目前在它上面(PRIMARY FOCUS
)。它是在
MaterialPageRoute
->PageRoute
->ModalRoute
->createOverlayEntries
->_buildModalScope
方法,调用_ModalScope
创建的。 -
再上一层
FocusScopeNode#4f0d5
是Navigator
,代码如下:
final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: 'Navigator Scope');
@override
Widget build(BuildContext context)
return HeroControllerScope(
child: Listener(
onPointerDown: _handlePointerDown,
onPointerUp: _handlePointerUpOrCancel,
onPointerCancel: _handlePointerUpOrCancel,
child: AbsorbPointer(
absorbing: false,
child: FocusScope(
node: focusScopeNode, // <---
autofocus: true,
child: Overlay(
key: _overlayKey,
initialEntries: overlay == null ? _allRouteOverlayEntries.toList(growable: false) : const <OverlayEntry>[],
),
),
),
),
);
- 再往上两层是
WidgetsApp
的Shortcuts
和FocusTraversalGroup
创建的。
- 最顶层就是
rootScope
它是在WidgetsBinding
初始化时调用BuildOwner
创建FocusManager
而来的。
mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, RendererBinding, SemanticsBinding
@override
void initInstances()
super.initInstances();
_buildOwner = BuildOwner();
...
...
class BuildOwner
/// Creates an object that manages widgets.
BuildOwner( this.onBuildScheduled );
/// The object in charge of the focus tree.
FocusManager focusManager = FocusManager();
...
class FocusManager with DiagnosticableTreeMixin, ChangeNotifier
final FocusScopeNode rootScope = FocusScopeNode(debugLabel: 'Root Focus Scope');
FocusManager()
rootScope._manager = this;
...
...
- 最后是
FocusManager
类的相关信息。
primaryFocus
:当前的主焦点。rootScope
:当前Focus Tree的根节点。highlightMode
:当前获取焦点的交互模式,上面有提到。highlightStrategy
:交互模式的策略,默认automatic
根据接收到的最后一种输入方式,自动切换。也可以指定使用某一种方式。FocusManager
也继承自ChangeNotifier
,所以我们可以通过addListener
监听primaryFocus
的变化。
3.Focus Tree变化
现在我先点击一下输入框,在点击按钮,打印结果如下(只取最后几层):
primaryFocus: FocusNode#e72e2([PRIMARY FOCUS])
...
└─Child 1: FocusScopeNode#af55c(_ModalScopeState<dynamic> Focus Scope [IN FOCUS PATH])
│ context: FocusScope
│ IN FOCUS PATH
│ focusedChildren: FocusNode#e72e2([PRIMARY FOCUS])
│
├─Child 1: FocusNode#e72e2([PRIMARY FOCUS])
│ context: EditableText-[LabeledGlobalKey<EditableTextState>#c2f8a]
│ PRIMARY FOCUS
│
└─Child 2: FocusNode#0b7c0
context: Focus
可以看到当前焦点primaryFocus
为FocusNode#e72e2
也就是到了TextField
上。注意这里的focusedChildren
此时只有FocusNode#e72e2
。
因为我点击了TextField
,此时软键盘弹出。现在我需要关闭软键盘,我这里有四种方法:
-
使用
SystemChannels.textInput.invokeMethod('TextInput.hide')
方法,这种方法关闭软键盘后焦点不变,还在TextField
上,所以有一个问题。比如这时你push到一个新的页面再pop返回,此时软键盘会再次弹出。这里不推荐使用。 -
使用
FocusScope.of(context).requestFocus(FocusNode())
方法,并打印一下Focus Tree
。
primaryFocus: FocusNode#7da34([PRIMARY FOCUS])
└─Child 1: FocusScopeNode#af55c(_ModalScopeState<dynamic> Focus Scope [IN FOCUS PATH])
│ context: FocusScope
│ IN FOCUS PATH
│ focusedChildren: FocusNode#7da34([PRIMARY FOCUS]),
│ FocusNode#e72e2
│
├─Child 1: FocusNode#e72e2
│ context: EditableText-[LabeledGlobalKey<EditableTextState>#c2f8a]
│
├─Child 2: FocusNode#0b7c0
│ context: Focus
└─Child 3: FocusNode#7da34([PRIMARY FOCUS])
PRIMARY FOCUS
可以看到其实就在当前节点下创建了一个FocusNode#7da34
并把焦点转移给它。注意这里的focusedChildren
此时有FocusNode#7da34
和FocusNode#e72e2
。
- 使用
FocusScope.of(context).unfocus()
方法重复上面的步骤,并打印一下Focus Tree
。
primaryFocus: FocusScopeNode#4f0d5(Navigator Scope [PRIMARY FOCUS])
└─Child 1: FocusScopeNode#4f0d5(Navigator Scope [PRIMARY FOCUS])
│ context: FocusScope
│ PRIMARY FOCUS
│
└─Child 1: FocusScopeNode#af55c(_ModalScopeState<dynamic> Focus Scope)
│ context: FocusScope
│ focusedChildren: FocusNode#e72e2, FocusNode#7da34
│
├─Child 1: FocusNode#e72e2
│ context: EditableText-[LabeledGlobalKey<EditableTextState>#c2f8a]
│
├─Child 2: FocusNode#0b7c0
│ context: Focus
└─Child 3: FocusNode#7da34
可以看到焦点直接到了Navigator
上,为什么不是当前页面FocusScopeNode#af55c
呢?
因为这里FocusScope.of(context)
方法所返回的FocusScopeNode
就是当前页面FocusScopeNode#af55c
,这时候你再取消了焦点,那么焦点此时就向上寻找,到了Navigator
上。
注意这里的focusedChildren
此时有FocusNode#e72e2
和FocusNode#7da34
。不过看到这里你有没有发现一个问题。焦点已经不在FocusScopeNode#af55c
的作用域里面了,但是focusedChildren
里却还存在数据,如果我们这时使用如FocusScope.of(context).focusedChild
方法,那么得到的结果就是不正确的。
稳妥的做法是使用下面的第四种方法。
- 最后一个方法就是给
TextField
添加属性focusNode
,直接调用_focusNode.unfocus()
:
final FocusNode _focusNode = FocusNode();
TextField(
focusNode: _focusNode,
),
_focusNode.unfocus();
这里我就不贴结果了,大体和一开始的一样,此时focusedChildren
为空不打印。这样就可以将焦点成功归还上级作用域(当前页面),不过这样如果页面复杂,可能会比较繁琐,你需要每个添加FocusNode
来管理。所以更推荐使用:
FocusManager.instance.primaryFocus?.unfocus();
它可以直接获取到当前的焦点,便于我们直接取消焦点。所以对比这四个方法,肯定后者比较好了,也避免了因数据错误导致的其他隐患。
4.结语
通过观察Focus Tree的变化,我们大致可以理解Focus Tree的组成及变化规律,如果你有控制焦点的需求,本篇或许可以为你带来帮助。
关于Focus其实还有许多细节,比如FocusAttachment
如何管理FocusNode
、FocusNode
的遍历顺序实现 FocusTraversalGroup
等。由于篇幅有限,这里就不介绍了,有兴趣的可以看看源码。
本篇是“说说”系列第四篇,前三篇链接奉上:
如果本文对你有所帮助或启发的话,还请不吝点赞收藏支持一波。同时也多多支持我的Flutter开源项目flutter_deer。
我们下个月见~~
5.参考
以上是关于说说Flutter中的无名英雄 —— Focus的主要内容,如果未能解决你的问题,请参考以下文章