瞭解 Flutter 的鍵盤焦點系統
如何在 Flutter 應用中使用焦點系統。
本文介紹瞭如何控制鍵盤輸入的指向。如果您正在實現一個使用物理鍵盤的應用程式(例如大多數桌面和 Web 應用程式),請閱讀本頁內容。如果您的應用不會與物理鍵盤一起使用,則可以跳過此部分。
概述
#Flutter 自帶了一套焦點系統,用於將鍵盤輸入指向應用程式的特定部分。為此,使用者透過點選所需的 UI 元素將輸入“聚焦”到應用程式的該部分。一旦發生這種情況,透過鍵盤輸入的文字將流向應用程式的該部分,直到焦點移動到應用程式的其他部分。焦點也可以透過按下特定的鍵盤快捷鍵(通常繫結到 Tab 鍵)來移動,因此這有時被稱為“Tab 遍歷”。
本頁探討了在 Flutter 應用程式中執行這些操作所使用的 API,以及焦點系統的工作原理。我們注意到開發者對如何定義和使用 FocusNode 物件存在一些困惑。如果這符合您的情況,請直接跳轉到 建立 FocusNode 物件的最佳實踐。
焦點使用場景
#以下是一些您可能需要了解如何使用焦點系統的情況示例:
術語表
#以下是 Flutter 焦點系統中使用的術語。下面將介紹實現這些概念的各種類。
- 焦點樹 (Focus tree) - 一棵焦點節點樹,通常稀疏地映象了小部件樹,代表所有可以接收焦點的小部件。
- 焦點節點 (Focus node) - 焦點樹中的單個節點。該節點可以接收焦點,當它成為焦點鏈的一部分時,被稱為“擁有焦點”。只有當它擁有焦點時,它才會參與處理按鍵事件。
- 主焦點 (Primary focus) - 焦點樹中距離根節點最遠且擁有焦點的節點。這是按鍵事件開始傳播到主焦點節點及其祖先的焦點節點。
- 焦點鏈 (Focus chain) - 一個有序的焦點節點列表,從主焦點節點開始,並沿著焦點樹的分支向上追溯到根節點。
- 焦點範圍 (Focus scope) - 一個特殊的焦點節點,其作用是包含一組其他焦點節點,並僅允許這些節點接收焦點。它包含有關其子樹中哪些節點先前獲得過焦點的資訊。
- 焦點遍歷 (Focus traversal) - 以預定義的順序從一個可聚焦節點移動到另一個節點的過程。這通常出現在使用者按下 Tab 鍵以移動到下一個可聚焦控制元件或欄位的應用程式中。
FocusNode 和 FocusScopeNode
#FocusNode 和 FocusScopeNode 物件實現了焦點系統的機制。它們是長生命週期的物件(生命週期比小部件長,類似於渲染物件),用於儲存焦點狀態和屬性,以便在小部件樹構建之間保持永續性。它們共同構成了焦點樹的資料結構。
它們最初旨在作為面向開發者的物件,用於控制焦點系統的某些方面,但隨著時間的推移,它們已演變為主要實現焦點系統的細節。為了避免破壞現有的應用程式,它們仍然包含用於其屬性的公共介面。但是,總的來說,它們最主要的用途是充當一個相對不透明的控制代碼,傳遞給後代小部件,以便在祖先小部件上呼叫 requestFocus(),從而請求後代小部件獲取焦點。除非您不使用它們或實現自己的版本,否則最好透過 Focus 或 FocusScope 小部件來管理其他屬性的設定。
建立 FocusNode 物件的最佳實踐
#關於使用這些物件的一些建議:
- 不要在每次構建 (build) 時都分配一個新的
FocusNode。這會導致記憶體洩漏,並且偶爾會導致小部件在擁有焦點時重新構建而丟失焦點。 - 在有狀態小部件 (StatefulWidget) 中建立
FocusNode和FocusScopeNode物件。FocusNode和FocusScopeNode在使用完畢後需要被銷燬,因此它們應該只在有狀態小部件的狀態物件內建立,在那裡您可以覆蓋dispose方法來銷燬它們。 - 不要在多個小部件中使用同一個
FocusNode。如果這樣做,這些小部件將爭奪節點的屬性管理權,結果很可能不如您所願。 - 設定焦點節點小部件的
debugLabel,以幫助診斷焦點問題。 - 如果
FocusNode或FocusScopeNode正由Focus或FocusScope小部件管理,請勿在這些節點上設定onKeyEvent回撥。如果您需要onKeyEvent處理程式,請在要監聽的小部件子樹周圍新增一個新的Focus小部件,並將該小部件的onKeyEvent屬性設定為您的處理程式。如果您也不希望它能夠獲得主焦點,請在該小部件上設定canRequestFocus: false。這是因為Focus小部件上的onKeyEvent屬性可能會在後續構建中被設定為其他值,如果發生這種情況,它會覆蓋您在節點上設定的onKeyEvent處理程式。 - 呼叫節點上的
requestFocus()來請求它獲取主焦點,尤其是從祖先節點將其擁有的節點傳遞給您想要聚焦的後代節點時。 - 請使用
focusNode.requestFocus()。無需呼叫FocusScope.of(context).requestFocus(focusNode)。focusNode.requestFocus()方法效果相同且效能更高。
取消焦點 (Unfocusing)
#有一個名為 FocusNode.unfocus() 的 API 用於告訴節點“放棄焦點”。雖然它確實會從節點中移除焦點,但必須認識到“取消所有節點的焦點”是不存在的。如果一個節點取消了焦點,那麼它必須將焦點傳遞到其他地方,因為系統總是存在一個主焦點。當節點呼叫 unfocus() 時接收焦點的節點是最近的 FocusScopeNode,或者是該範圍內先前聚焦的節點,具體取決於傳遞給 unfocus() 的 disposition 引數。如果您想在從節點移除焦點時更好地控制焦點去向,請顯式聚焦另一個節點,或者使用焦點遍歷機制,透過 FocusNode 上的 focusInDirection、nextFocus 或 previousFocus 方法查詢另一個節點。
呼叫 unfocus() 時,disposition 引數允許兩種取消焦點的模式:UnfocusDisposition.scope 和 UnfocusDisposition.previouslyFocusedChild。預設值為 scope,它將焦點交給最近的父級焦點範圍。這意味著如果此後使用 FocusNode.nextFocus 將焦點移動到下一個節點,它將從範圍內的“第一個”可聚焦專案開始。
previouslyFocusedChild 配置將搜尋範圍以找到先前聚焦的子項並請求焦點。如果沒有先前聚焦的子項,它等同於 scope。
Focus 小部件
#Focus 小部件擁有並管理一個焦點節點,是焦點系統的核心元件。它管理其擁有的焦點節點與焦點樹的連線和斷開,管理焦點節點的屬性和回撥,並具有靜態函式以支援發現連線到小部件樹的焦點節點。
以最簡單的形式,在小部件子樹周圍包裹 Focus 小部件,可以使該小部件子樹作為焦點遍歷過程的一部分獲取焦點,或者在傳遞給它的 FocusNode 上呼叫 requestFocus 時獲取焦點。結合呼叫 requestFocus 的手勢檢測器,它可以在點選時獲取焦點。
您可以將 FocusNode 物件傳遞給 Focus 小部件進行管理,如果不這樣做,它會建立自己的節點。建立自己的 FocusNode 的主要原因是能夠對該節點呼叫 requestFocus(),從而從父小部件控制焦點。FocusNode 的大多數其他功能最好透過更改 Focus 小部件本身的屬性來訪問。
Focus 小部件在 Flutter 的大多數自帶控制元件中用於實現其焦點功能。
以下是一個示例,展示瞭如何使用 Focus 小部件使自定義控制元件可聚焦。它建立了一個容器,其中的文字會對接收焦點做出反應。
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
static const String _title = 'Focus Sample';
@override
Widget build(BuildContext context) {
return MaterialApp(
title: _title,
home: Scaffold(
appBar: AppBar(title: const Text(_title)),
body: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[MyCustomWidget(), MyCustomWidget()],
),
),
);
}
}
class MyCustomWidget extends StatefulWidget {
const MyCustomWidget({super.key});
@override
State<MyCustomWidget> createState() => _MyCustomWidgetState();
}
class _MyCustomWidgetState extends State<MyCustomWidget> {
Color _color = Colors.white;
String _label = 'Unfocused';
@override
Widget build(BuildContext context) {
return Focus(
onFocusChange: (focused) {
setState(() {
_color = focused ? Colors.black26 : Colors.white;
_label = focused ? 'Focused' : 'Unfocused';
});
},
child: Center(
child: Container(
width: 300,
height: 50,
alignment: Alignment.center,
color: _color,
child: Text(_label),
),
),
);
}
}
按鍵事件
#如果您希望在子樹中監聽按鍵事件,請將 Focus 小部件的 onKeyEvent 屬性設定為一個處理程式,該處理程式要麼僅監聽按鍵,要麼處理按鍵並停止其向其他小部件傳播。
按鍵事件從擁有主焦點的焦點節點開始。如果該節點在其 onKeyEvent 處理程式中沒有返回 KeyEventResult.handled,則事件會傳遞給其父焦點節點。如果父節點沒有處理它,它會傳遞給其父節點,依此類推,直到到達焦點樹的根部。如果事件到達焦點樹的根部而未被處理,則會將其返回給平臺,交給應用程式中的下一個原生控制元件(以防 Flutter UI 是更大的原生應用程式 UI 的一部分)。已處理的事件不會傳播到其他 Flutter 小部件,也不會傳播到原生小部件。
以下是一個 Focus 小部件的示例,它會吸收其子樹未處理的每個按鍵,且本身無法成為主焦點:
@override
Widget build(BuildContext context) {
return Focus(
onKeyEvent: (node, event) => KeyEventResult.handled,
canRequestFocus: false,
child: child,
);
}
焦點按鍵事件的處理優先於文字輸入事件,因此當焦點小部件環繞文字欄位時,處理按鍵事件會阻止該按鍵輸入到文字欄位中。
這是一個小部件示例,它不允許字母“a”被輸入到文字欄位中:
@override
Widget build(BuildContext context) {
return Focus(
onKeyEvent: (node, event) {
return (event.logicalKey == LogicalKeyboardKey.keyA)
? KeyEventResult.handled
: KeyEventResult.ignored;
},
child: const TextField(),
);
}
如果意圖是輸入驗證,這個示例的功能可能最好使用 TextInputFormatter 來實現,但該技術仍然很有用:例如,Shortcuts 小部件使用這種方法在快捷鍵成為文字輸入之前處理它們。
控制獲取焦點的內容
#焦點的核心方面之一是控制什麼可以接收焦點以及如何接收。屬性 canRequestFocus、skipTraversal 和 descendantsAreFocusable 控制此節點及其後代如何參與焦點過程。
如果 skipTraversal 屬性為 true,則此焦點節點不參與焦點遍歷。如果在其焦點節點上呼叫 requestFocus,它仍然是可聚焦的,但在焦點遍歷系統尋找下一個要聚焦的內容時會被跳過。
canRequestFocus 屬性控制此 Focus 小部件管理的焦點節點是否可用於請求焦點。如果此屬性為 false,則在節點上呼叫 requestFocus 無效。這也意味著此節點在焦點遍歷中被跳過,因為它無法請求焦點。
descendantsAreFocusable 屬性控制此節點的後代是否可以接收焦點,但仍允許此節點接收焦點。此屬性可用於關閉整個小部件子樹的可聚焦性。這就是 ExcludeFocus 小部件的工作原理:它只是一個設定了此屬性的 Focus 小部件。
自動聚焦 (Autofocus)
#設定 Focus 小部件的 autofocus 屬性會告訴該小部件在它所屬的焦點範圍第一次被聚焦時請求焦點。如果設定了 autofocus 的小部件超過一個,那麼哪個小部件獲得焦點是不確定的,因此儘量只在每個焦點範圍內的一個小部件上設定它。
autofocus 屬性僅在節點所屬的範圍內尚無焦點時生效。
在屬於不同焦點範圍的兩個節點上設定 autofocus 屬性定義明確:當它們各自的範圍獲得焦點時,每個節點都會成為該範圍的焦點小部件。
更改通知
#Focus.onFocusChanged 回撥可用於接收特定節點的焦點狀態已更改的通知。它會在節點被新增或從焦點鏈中移除時通知,這意味著即使它不是主焦點,也會收到通知。如果您只想知道是否已獲得主焦點,請檢查焦點節點上的 hasPrimaryFocus 是否為 true。
獲取 FocusNode
#有時,獲取 Focus 小部件的焦點節點以查詢其屬性很有用。
要從 Focus 小部件的祖先訪問焦點節點,請建立一個 FocusNode 並將其作為 Focus 小部件的 focusNode 屬性傳入。因為它需要被銷燬,所以您傳入的焦點節點需要由有狀態小部件擁有,因此不要每次構建時都建立一個新節點。
如果您需要從 Focus 小部件的後代訪問焦點節點,可以呼叫 Focus.of(context) 來獲取給定上下文中最近的 Focus 小部件的焦點節點。如果您需要在同一個構建函式內獲取 Focus 小部件的 FocusNode,請使用 Builder 以確保您擁有正確的上下文。以下示例展示了這一點:
@override
Widget build(BuildContext context) {
return Focus(
child: Builder(
builder: (context) {
final bool hasPrimary = Focus.of(context).hasPrimaryFocus;
print('Building with primary focus: $hasPrimary');
return const SizedBox(width: 100, height: 100);
},
),
);
}
時序
#焦點系統的一個細節是,當請求焦點時,它僅在當前構建階段完成後生效。這意味著焦點更改總是會延遲一幀,因為更改焦點可能會導致小部件樹的任意部分重新構建,包括當前請求焦點的小部件的祖先。由於後代不能讓祖先變髒 (dirty),因此它必須在幀之間發生,以便所需的任何更改可以在下一幀中進行。
FocusScope 小部件
#FocusScope 小部件是 Focus 小部件的特殊版本,它管理 FocusScopeNode 而不是 FocusNode。FocusScopeNode 是焦點樹中的一個特殊節點,用作子樹中焦點節點的分組機制。焦點遍歷會保留在焦點範圍 (focus scope) 內,除非顯式聚焦了範圍之外的節點。
焦點範圍還會跟蹤當前焦點以及在其子樹中聚焦過的節點的歷史記錄。這樣,如果一個節點釋放了焦點,或者在擁有焦點時被移除,焦點可以返回到先前擁有焦點的節點。
焦點範圍還充當了當沒有後代擁有焦點時返回焦點的歸宿。這使得焦點遍歷程式碼可以有一個起始上下文,用於查詢要移動到的下一個(或第一個)可聚焦控制元件。
如果您聚焦一個焦點範圍節點,它會首先嚐試聚焦其子樹中當前或最近聚焦的節點,或者其子樹中請求自動聚焦的節點(如果有)。如果沒有這樣的節點,它將自己獲得焦點。
FocusableActionDetector 小部件
#FocusableActionDetector 是一個結合了 Actions、Shortcuts、MouseRegion 和 Focus 小部件功能的元件,它建立了一個定義操作和按鍵繫結的檢測器,並提供用於處理焦點和懸停高亮的回撥。這就是 Flutter 控制元件實現這些方面功能的方式。它只是使用這些組成部分的小部件來實現的,因此如果您不需要它的所有功能,只需使用您需要的部分即可,但它是將這些行為構建到自定義控制元件中的一種便捷方式。
控制焦點遍歷
#一旦應用程式具備了焦點功能,許多應用接下來想要做的是允許使用者使用鍵盤或其他輸入裝置來控制焦點。最常見的例子是“Tab 遍歷”,即使用者按下 Tab 鍵前往“下一個”控制元件。控制“下一個”是什麼就是本節的主題。Flutter 預設提供了這種遍歷。
在簡單的網格佈局中,很容易決定哪個控制元件是下一個。如果您不在行尾,那麼就是右側的那個(如果是從右到左的語言環境,則是左側)。如果您在行尾,則它是下一行的第一個控制元件。不幸的是,應用程式很少以網格佈局,因此通常需要更多的指導。
Flutter 中焦點遍歷的預設演算法 (ReadingOrderTraversalPolicy) 非常好:它為大多數應用程式提供了正確的結果。然而,總會有一些特殊情況,或者上下文和設計需要與預設排序演算法得出的順序不同的情況。對於這些情況,有其他機制來實現所需的順序。
FocusTraversalGroup 小部件
#FocusTraversalGroup 小部件應放置在小部件子樹周圍,這些子樹應在移動到另一個小部件或小部件組之前被完全遍歷。通常,只需將小部件分組到相關組中就足以解決許多 Tab 遍歷排序問題。如果不能,還可以為該組指定一個 FocusTraversalPolicy 來確定組內的順序。
預設的 ReadingOrderTraversalPolicy 通常就足夠了,但在需要更多排序控制的情況下,可以使用 OrderedTraversalPolicy。包裹在可聚焦元件周圍的 FocusTraversalOrder 小部件的 order 引數決定了順序。順序可以是 FocusOrder 的任何子類,但 Flutter 提供了 NumericFocusOrder 和 LexicalFocusOrder。
如果提供的焦點遍歷策略都不足以滿足您的應用程式需求,您還可以編寫自己的策略,並用它來確定您想要的任何自定義順序。
這是一個如何使用 FocusTraversalOrder 小部件按“二、一、三”的順序(使用 NumericFocusOrder)遍歷一排按鈕的示例:
class OrderedButtonRow extends StatelessWidget {
const OrderedButtonRow({super.key});
@override
Widget build(BuildContext context) {
return FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Row(
children: <Widget>[
const Spacer(),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextButton(child: const Text('ONE'), onPressed: () {}),
),
const Spacer(),
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextButton(child: const Text('TWO'), onPressed: () {}),
),
const Spacer(),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: TextButton(child: const Text('THREE'), onPressed: () {}),
),
const Spacer(),
],
),
);
}
}
FocusTraversalPolicy (焦點遍歷策略)
#FocusTraversalPolicy 是一個物件,它根據請求和當前焦點節點確定下一個小部件是什麼。請求(成員函式)包括 findFirstFocus、findLastFocus、next、previous 和 inDirection。
FocusTraversalPolicy 是具體策略(如 ReadingOrderTraversalPolicy、OrderedTraversalPolicy 和 DirectionalFocusTraversalPolicyMixin 類)的抽象基類。
要使用 FocusTraversalPolicy,請將其提供給 FocusTraversalGroup,它決定了策略生效的小部件子樹。該類的成員函式很少被直接呼叫:它們是供焦點系統使用的。
焦點管理器 (Focus manager)
#FocusManager 維護系統的當前主焦點。它只有少量對焦點系統使用者有用的 API。其中之一是 FocusManager.instance.primaryFocus 屬性,它包含當前聚焦的焦點節點,也可以從全域性 primaryFocus 欄位訪問。
其他有用的屬性包括 FocusManager.instance.highlightMode 和 FocusManager.instance.highlightStrategy。這些屬性由需要切換“觸控”模式和“傳統”(滑鼠和鍵盤)模式以獲取焦點高亮的小部件使用。當用戶使用觸控進行導航時,焦點高亮通常會被隱藏;當他們切換到滑鼠或鍵盤時,需要再次顯示焦點高亮,以便他們知道什麼被聚焦了。highlightStrategy 告訴焦點管理器如何解釋裝置使用模式的變化:它可以根據最近的輸入事件自動在兩者之間切換,也可以鎖定在觸控或傳統模式。Flutter 中的現有小部件已經知道如何使用此資訊,因此只有在您從頭開始編寫自己的控制元件時才需要它。您可以使用 addHighlightModeListener 回撥來監聽高亮模式的變化。