說說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 。
我們下個月見~~