说说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 枚举类是焦点取消后的行为,分为 scopepreviouslyFocusedChild 两种。

  1. scope 表示向上寻找最近的 FocusScopeNode
  2. previouslyFocusedChild 是寻找上一个焦点位置,如果没有则给当前 FocusScopeNode

具体实现可见 unfocus 源码,这里就不多说了。

  • dispose 这个没啥说的,注意使用 FocusNode 完后及时销毁。

1.2 FocusScopeNode

FocusScopeNodeFocusNode 的子类。它将 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 _focusedChildren = [];

注意这里的 _focusedChildren 并不是 FocusScopeNode 下出现的所有 FocusNode ,而是获取过焦点的 FocusNode 才会在里面。源码实现如下:

void _setAsFocusedChildForScope() {
    FocusNode scopeFocus = this;
    for (final FocusScopeNode ancestor in ancestors.whereType()) {
      // 从聚焦的历史中移除
      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 ,它是表示使用何种交互模式获取的焦点。分为 touchtraditional

默认的区分实现如下:

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

FocusScopeFocus 类似,不过它的内部管理的是 FocusScopeNode 。它不改变主焦点,它只是改变了接收焦点的作用域节点。这个在源码中使用的不多,但却都很重要的位置。

比如 NavigatorRoute ,首先 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 里一个 TextFieldFlatButton

class _MyHomePageState extends State {

  @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
  │   Focus Scope [PRIMARY FOCUS])
  │ nextFocus: FocusScopeNode#4f0d5(Navigator Scope [IN FOCUS PATH])
  │ primaryFocusCreator: FocusScope ← _ActionsMarker ← Actions ←
  │   PageStorage ← Offstage ← _ModalScopeStatus ←
  │   _ModalScope-[LabeledGlobalKey<_ModalScopeState>#bfb70]
  │   ← _EffectiveTickerMode ← TickerMode ←
  │   _OverlayEntryWidget-[LabeledGlobalKey#3fa85]
  │   ← _Theatre ← Overlay-[LabeledGlobalKey#2d724] ←
  │   _FocusMarker ← Semantics ← FocusScope ← AbsorbPointer ←
  │   _PointerListener ← Listener ← HeroControllerScope ←
  │   Navigator-[GlobalObjectKey
  │   _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 Focus Scope [PRIMARY FOCUS])
            │ context: FocusScope
            │ PRIMARY FOCUS
            │
            ├─Child 1: FocusNode#e72e2
            │   context: EditableText-[LabeledGlobalKey#c2f8a]
            │
            └─Child 2: FocusNode#0b7c0
                context: Focus

我从下往上说一下代表的含义:

  1. Child 1: FocusNode#e72e2Child 2: FocusNode#0b7c0 一看就是同级,代表的就是 TextFieldFlatButton
  2. 上一层 FocusScopeNode#af55c 是当前的页面,可以看到焦点目前在它上面( PRIMARY FOCUS )。它是在

MaterialPageRoute -> PageRoute -> ModalRoute -> createOverlayEntries -> _buildModalScope 方法,调用 _ModalScope 创建的。

  1. 再上一层 FocusScopeNode#4f0d5Navigator ,代码如下:
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 [],
            ),
          ),
        ),
      ),
    );
  }
  1. 再往上两层是 WidgetsAppShortcutsFocusTraversalGroup 创建的。

  1. 最顶层就是 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;
    ...
  }
  ...
}
  1. 最后是 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 Focus Scope [IN FOCUS PATH])
  │ context: FocusScope
  │ IN FOCUS PATH
  │ focusedChildren: FocusNode#e72e2([PRIMARY FOCUS])
  │
  ├─Child 1: FocusNode#e72e2([PRIMARY FOCUS])
  │   context: EditableText-[LabeledGlobalKey#c2f8a]
  │   PRIMARY FOCUS
  │
  └─Child 2: FocusNode#0b7c0
      context: Focus

可以看到当前焦点 primaryFocusFocusNode#e72e2 也就是到了 TextField 上。注意这里的 focusedChildren 此时只有 FocusNode#e72e2

因为我点击了 TextField ,此时软键盘弹出。现在我需要关闭软键盘,我这里有四种方法:

  1. 使用 SystemChannels.textInput.invokeMethod('TextInput.hide') 方法,这种方法关闭软键盘后焦点不变,还在 TextField 上,所以有一个问题。比如这时你push到一个新的页面再pop返回,此时软键盘会再次弹出。这里不推荐使用。
  2. 使用 FocusScope.of(context).requestFocus(FocusNode()) 方法,并打印一下 Focus Tree
primaryFocus: FocusNode#7da34([PRIMARY FOCUS])
└─Child 1: FocusScopeNode#af55c(_ModalScopeState Focus Scope [IN FOCUS PATH])
  │ context: FocusScope
  │ IN FOCUS PATH
  │ focusedChildren: FocusNode#7da34([PRIMARY FOCUS]),
  │   FocusNode#e72e2
  │
  ├─Child 1: FocusNode#e72e2
  │   context: EditableText-[LabeledGlobalKey#c2f8a]
  │
  ├─Child 2: FocusNode#0b7c0
  │   context: Focus
  └─Child 3: FocusNode#7da34([PRIMARY FOCUS])
      PRIMARY FOCUS

可以看到其实就在当前节点下创建了一个 FocusNode#7da34 并把焦点转移给它。注意这里的 focusedChildren 此时有 FocusNode#7da34FocusNode#e72e2

  1. 使用 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 Focus Scope)
    │ context: FocusScope
    │ focusedChildren: FocusNode#e72e2, FocusNode#7da34
    │
    ├─Child 1: FocusNode#e72e2
    │   context: EditableText-[LabeledGlobalKey#c2f8a]
    │
    ├─Child 2: FocusNode#0b7c0
    │   context: Focus   
    └─Child 3: FocusNode#7da34

可以看到焦点直接到了 Navigator 上,为什么不是当前页面 FocusScopeNode#af55c 呢?

因为这里 FocusScope.of(context) 方法所返回的 FocusScopeNode 就是当前页面 FocusScopeNode#af55c ,这时候你再取消了焦点,那么焦点此时就向上寻找,到了 Navigator 上。

注意这里的 focusedChildren 此时有 FocusNode#e72e2FocusNode#7da34 。不过看到这里你有没有发现一个问题。焦点已经不在 FocusScopeNode#af55c 的作用域里面了,但是 focusedChildren 里却还存在数据,如果我们这时使用如 FocusScope.of(context).focusedChild 方法,那么得到的结果就是不正确的。

稳妥的做法是使用下面的第四种方法。

  1. 最后一个方法就是给 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 如何管理 FocusNodeFocusNode 的遍历顺序实现 FocusTraversalGroup 等。由于篇幅有限,这里就不介绍了,有兴趣的可以看看源码。

本篇是“说说”系列第四篇,前三篇链接奉上:

如果本文对你有所帮助或启发的话,还请不吝点赞收藏支持一波。同时也多多支持我的Flutter开源项目 flutter_deer

我们下个月见~~

5.参考