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<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 ,它是表示使用何種交互模式獲取的焦點。分爲 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<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

我從下往上說一下代表的含義:

  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 <OverlayEntry>[],
            ),
          ),
        ),
      ),
    );
  }
  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<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

可以看到當前焦點 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<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#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<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#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.參考

相關文章