【Flutter】Focus 入門 ! Focus の理解に必要な登場人物と、用語をざっくり解説
はじめに
この記事の目的は、Flutter における Focus のシステムを理解したいと思う人の入り口として、
Focus
の学習に必要な登場人物や用語をできるだけ網羅的に、短く端的にまとめることを目的としています。
この記事を通して生まれた疑問は、適宜調べたり動かしたりしながら解消すると、より理解が進むと思います。
できればその過程などコメントくれると嬉しいです(必要そうなら追記したりもしていきます)。
登場人物
クラス名 | ざっくり役割 |
---|---|
Focus ウィジェット |
フォーカス可能なウィジェット(TextField の内部にあったりする) |
FocusNode |
Focus ウィジェットが保持する「自身のフォーカス」に関する情報 |
FocusScope ウィジェット / FocusScopeNode
|
子孫の FocusNode も保持した FocusNode
|
FocusManager |
主に primaryFocus の追跡をするシングルトンで、キーボードイベントの通知先を指定している |
FocusAttachment |
Focus ウィジェットが保持している FocusNode を、Focus ツリーへのアタッチをするクラス |
FocusTraversalGroup ウィジェット / FocusOrder
|
traversal(タブキーなどでのフォーカスの移動)の順番をカスタムするためのシステム |
FocusNode/Focus 基礎
FocusNode
は、「フォーカスしているかどうか」などの状態を含めた、フォーカス可能なウィジェットの、フォーカスに関する情報を保持した ChangeNotifier
です。
「フォーカス可能なウィジェット」とは、Focus
ウィジェットのことを言います(もしくはその継承)。
FocusNode
と Focus
ウィジェットは基本的に1体1の関係になります。
class _MyWidgetState extends State<MyWidget> {
final _focusNode1 = FocusNode():
final _focusNode2 = FocusNode():
Widget build(BuildContext context) {
Focus(
focusNode: _focusNode1,
);
// 内部で FocusNode を使用
TextField(
focusNode: _focusNode2,
);
そのフォーカスがキーボードイベントをイベントを受け取っているフォーカスであるかどうかは、hasPrimaryFocus
で確実に受け取ることができます。
class _MyWidgetState extends State<MyWidget> {
final _focusNode = FocusNode();
Widget build(BuildContext context) {
ListenableBuilder(
listenable: _focusNode,
builder: (context, _) {
return Text(_focusNode.hasPromaryFocus ? 'フォーカス中' : 'フォーカスしてないかも' );
}
)
}
加えて、フォーカスさせるために関数も用意されています。
class _MyWidgetState extends State<MyWidget> {
final _focusNode = FocusNode();
//...
void hogeFunc() {
// フォーカスを、「次のフォーカス可能なウィジェット」へ遷移
_focusNode.nextFocus();
// フォーカスを、「前のフォーカス可能なウィジェット」へ遷移
_focusNode.previousFocus();
// 対象のフォーカス可能なウィジェットへフォーカス
_focusNode.requestFocus();
}
では、「次のフォーカス」や「前のフォーカス」はどのように管理されているのでしょうか?
FocusNode の parent/children
FocusNode
には、親(FocusNode? parent
) と子List<FocusNode> children
という2つの変数の保持しています。
これらはウィジェットツリーと連携しており、ウィジェットツリーと同様の階層のツリーを形成します。
class _MyWidgetState extends State<MyWidget> {
final _parentFocusNode = FocusNode(debugLabel: 'parent');
final _childFocusNode = FocusNode(debugLabel: 'child');
Widget build(BuildContext context) {
return Focus(
focusNode: _parentFocusNode,
child: Focus(
focusNode: _childFocusNode,
child: TextButton(
onPressed: () {
print(_parentFocusNode.children.map((e) => e.debugLabel));
// ('child')
print(_childFocusNode.parent?.debugLabel);
// 'parent'
},
child: Text('print')
);
),
);
フォーカスツリーとウィジェットツリーの連携は Focus
ウィジェットに FocusNode
を渡すと内部で FocusAttachment
クラスが生成され、自動で行ってくれます。
hasFocus と hasPrimaryFocus
フォーカスしているかをあらはす真偽値として、hasFocus
と hasPrimaryFocus
があります。
この違いはなんでしょうか。
hasFocus
は、子孫がフォーカスしている時にでも、true
を返してしまいます。
しかし hasPrimaryFocus
は、「hasFocus
が true
であり、さらに子孫がいない場合(ツリーの末尾)」に場合のみ true
を返します。
さっき、「hasPrimaryFocus
でフォーカスがキーボードイベントをイベントを受け取っているフォーカスであるかどうかを確実受け取れる」と書いたのは、それが理由です。
class _MyWidgetState extends State<MyWidget> {
final _parentFocusNode = FocusNode(debugLabel: 'parent');
final _childFocusNode = FocusNode(debugLabel: 'child');
Widget build(BuildContext context) {
return Focus(
focusNode: _parentFocusNode,
child: Focus(
focusNode: _childFocusNode,
child: TextButton(
onPressed: () {
_childFocusNode.requestFocus();
print(_parentFocusNode.hasFocus);
// true
print(_parentFocusNode.hasPrimaryFocus);
// false
print(_childFocusNode.parent?.debugLabel);
// 'parent'
},
child: Text('print')
);
),
);
Focus
の下に Focus
が来ることはほぼないと思いますが、確実にフォーカスしていることを取得するのなら、hasPrimaryFocus
が便利かもしれません。
「次のフォーカス」や「前のフォーカス」とは
前の疑問に戻りましょう。
これは、子孫である children
が配列であることによって成り立っています。
「次のフォーカス(nextFocus()
)」を実行すると、次の要素の FocusNode
へ移動します。
FocusScopeNode/FocusScope
FocusNode
の子孫を、スコープで囲みます。
これによって、タブで移動する時にループしてくれたりします。
FocusScope(
child: Column(
children: [
TextField(
textInputAction: TextInputAction.next,
),
TextField(
textInputAction: TextInputAction.next,
),
],
)
)
なので、FocusScope
で個別で囲んでしまうと、タブ移動が叶わなくなります。
FocusScope(
child: Column(
children: [
FocusScope(
child: TextField(
textInputAction: TextInputAction.next,
),
)
TextField(
textInputAction: TextInputAction.next,
),
],
)
)
ModalRoute と FocusScope
ページを形成する ModalRoute
の内部が FocusScope
で囲まれているので、ページ内の FocusNode
は、FocusScope
によってスコープされています。
なので、FocusScope
で自前でわざわざ囲まなくても勝手にループしてくれます。
FocusTraversalGroup
先ほどから登場している、「タブでの移動」という行為には、traversal という名前がついています。
traversal は、意識しなければ FocusNode
の children の配列順に移動しますが、それをカスタマイズすることができます。
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Row(
children: [
// 順番が反対になっている
FocusTraversalOrder(
order: NumericFocusOrder(1),
),
FocusTraversalOrder(
order: NumericFocusOrder(0),
),
]
),
),
正直長くなるので、とりあえず「そういうものがある」程度でいいと思います。
使いたくなったら、公式の FocusTraversalGroup
のページを見れば、十分理解できると思います。
- https://api.flutter.dev/flutter/widgets/FocusTraversalGroup-class.html
- https://api.flutter.dev/flutter/widgets/FocusTraversalOrder-class.html
FocusManager
シングルトンでどこからでもアクセスできるクラスです。
「どこが primaryFocus か」常に監視していて、キーボードイベントを送っている張本人です(多分)。
なので、フォーカスを外したい場合はどこからでも unfocus
を呼び出すことなどが可能です。
FocusManager.instance.primaryFocus?.unfocus();
rootScope
FocusManager
は、rootScope というのを常に保持しており、フォーカスツリーの祖先として機能します。
class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
final FocusScopeNode rootScope = FocusScopeNode(debugLabel: 'Root Focus Scope');
FocusAttachment
で親を設定する時、Focus
ウィジェットが祖先にない場合は rootScope を入れることで実現しています。
class FocusAttachment {
void reparent({FocusNode? parent}) {
if (isAttached) {
assert(_node.context != null);
parent ??= Focus.maybeOf(_node.context!, scopeOk: true);
parent ??= _node.context!.owner!.focusManager.rootScope;
parent._reparent(_node);
}
}
どこかのフォーカスが外れた時、FocusManager.instance.primaryFocus
は、rootScope に代わりに置き換わるようになっています。
FocusManager.instance.primaryFocus?.unfocus();
print(FocusManager.instance.primaryFocus?.debugLabel);
// 'Root Focus Scope'
Discussion