🔎

【Flutter】Focus 入門 ! Focus の理解に必要な登場人物と、用語をざっくり解説

2024/05/06に公開

はじめに

この記事の目的は、Flutter における Focus のシステムを理解したいと思う人の入り口として、
Focus の学習に必要な登場人物や用語をできるだけ網羅的に、短く端的にまとめることを目的としています。

この記事を通して生まれた疑問は、適宜調べたり動かしたりしながら解消すると、より理解が進むと思います。
できればその過程などコメントくれると嬉しいです(必要そうなら追記したりもしていきます)。

登場人物

クラス名 ざっくり役割
Focus ウィジェット フォーカス可能なウィジェット(TextField の内部にあったりする)
FocusNode Focus ウィジェットが保持する「自身のフォーカス」に関する情報
FocusScope ウィジェット / FocusScopeNode 子孫の FocusNode も保持した FocusNode
FocusManager 主に primaryFocus の追跡をするシングルトンで、キーボードイベントの通知先を指定している
FocusAttachment Focus ウィジェットが保持している FocusNode を、Focus ツリーへのアタッチをするクラス
FocusTraversalGroup ウィジェット / FocusOrder traversal(タブキーなどでのフォーカスの移動)の順番をカスタムするためのシステム

FocusNode/Focus 基礎

https://api.flutter.dev/flutter/widgets/FocusNode-class.html

https://api.flutter.dev/flutter/widgets/Focus-class.html

FocusNode は、「フォーカスしているかどうか」などの状態を含めた、フォーカス可能なウィジェットの、フォーカスに関する情報を保持した ChangeNotifier です。

「フォーカス可能なウィジェット」とは、Focus ウィジェットのことを言います(もしくはその継承)。
FocusNodeFocus ウィジェットは基本的に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

フォーカスしているかをあらはす真偽値として、hasFocushasPrimaryFocus があります。
この違いはなんでしょうか。

hasFocus は、子孫がフォーカスしている時にでも、true を返してしまいます。
しかし hasPrimaryFocus は、「hasFocustrue であり、さらに子孫がいない場合(ツリーの末尾)」に場合のみ 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

https://api.flutter.dev/flutter/widgets/FocusScopeNode-class.html

https://api.flutter.dev/flutter/widgets/FocusScope-class.html

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 で自前でわざわざ囲まなくても勝手にループしてくれます。

https://github.com/flutter/flutter/blob/54e66469a933b60ddf175f858f82eaeb97e48c8d/packages/flutter/lib/src/widgets/routes.dart#L939

FocusTraversalGroup

https://api.flutter.dev/flutter/widgets/FocusTraversalGroup-class.html

先ほどから登場している、「タブでの移動」という行為には、traversal という名前がついています。
traversal は、意識しなければ FocusNode の children の配列順に移動しますが、それをカスタマイズすることができます。

FocusTraversalGroup(
  policy: OrderedTraversalPolicy(),
  child: Row(
    children: [
      // 順番が反対になっている
      FocusTraversalOrder(
        order: NumericFocusOrder(1),
      ),
      FocusTraversalOrder(
        order: NumericFocusOrder(0),
      ),
    ]
  ),
),

正直長くなるので、とりあえず「そういうものがある」程度でいいと思います。
使いたくなったら、公式の FocusTraversalGroup のページを見れば、十分理解できると思います。

FocusManager

https://api.flutter.dev/flutter/widgets/FocusManager-class.html

シングルトンでどこからでもアクセスできるクラスです。
「どこが 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