flutterdesktop

How do I switch focus groups (via keyboard) immediately?


When traversing from one focus group to the next (with tab on keyboard) I expect the focus to move to the first field in the next group, but it seems to focus on nothing - and then another tab moves into that group.

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: SizedBox(
          width: double.infinity,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              for (var i = 0; i < 2; i++)
                FocusableActionDetector(
                  onFocusChange: (focused) {
                    if (!focused) {
                      print('Have left focus group');
                    }
                  },
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      for (var i = 0; i < 3; i++)
                        SizedBox(
                          width: 150,
                          child: TextField(),
                        ),
                    ],
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

Dartpad example

I expect that after the last field in the first group a press of Tab should move focus immediately to the first item in the next focus group.

I have tried all manner of FocusNode, FocusScope, FocusScope.of(context).___, however I am finding the focus management in Flutter a bit confusing.


Solution

  • Preface: I'm not an expert on Focus and find it quite convoluted myself.

    But the below works for what you're after, I believe, traversing from one column to the next, without focusing "invisible" items. At least on mobile devices. Web platform... that's a whole different ballgame (and I doubt it works the same).

    I got rid of the FocusableActionDetector (which acts as a FocusNode itself) and wrapped each Column in a FocusTraversalGroup. I believe Flutter tries to go from TraversalGroup to TraversalGroup when it can.

    The FocusScope wrapping the TraversalGroups prevents the Back button and any other clickable items from getting focus (once the FocusScope has gained focus).

    class MyHomePage2 extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: SizedBox(
              width: double.infinity,
              child: FocusScope( // LIMIT FOCUS TO DESCENDANTS
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    for (var i = 0; i < 2; i++)
                      FocusTraversalGroup( // CREATE GROUPS HERE
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            for (var i = 0; i < 3; i++)
                              SizedBox(
                                width: 150,
                                child: TextField(),
                              ),
                          ],
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
        );
      }
    }
    

    Summary

    (to the best of my knowledge, which is fuzzy)

    Debug

    Dumping the focus tree using debugDumpFocusTree (a static function available everywhere) can be helpful in debugging.

    I sometimes add it to the AppBar for easy, on-demand access:

        return Scaffold(
          appBar: AppBar(
            title: Text('Focus Tab Page'),
            actions: [
              IconButton(icon: Icon(Icons.info_outline), onPressed: debugDumpFocusTree)
            ],
          ),
    

    I've copied to relevant part below.

    └─Child 2: FocusScopeNode#c83f0(_ModalScopeState<dynamic> Focus Scope [IN FOCUS PATH])
      │ context: FocusScope
      │ IN FOCUS PATH
      │ focusedChildren: FocusScopeNode#7d536([IN FOCUS PATH])
      │
      ├─Child 1: FocusScopeNode#7d536([IN FOCUS PATH])
      │ │ context: FocusScope
      │ │ IN FOCUS PATH
      │ │ focusedChildren: FocusNode#1b886([PRIMARY FOCUS]),
      │ │   FocusNode#72c3b, FocusNode#34b25, FocusNode#3b410,
      │ │   FocusNode#02fac, FocusNode#61cd5
      │ │
      │ ├─Child 1: FocusNode#1d51e(FocusTraversalGroup)
      │ │ │ context: Focus
      │ │ │ NOT FOCUSABLE
      │ │ │
      │ │ ├─Child 1: FocusNode#3b410
      │ │ │   context: EditableText-[LabeledGlobalKey<EditableTextState>#70699]
      │ │ │
      │ │ ├─Child 2: FocusNode#34b25
      │ │ │   context: EditableText-[LabeledGlobalKey<EditableTextState>#7c822]
      │ │ │
      │ │ └─Child 3: FocusNode#72c3b
      │ │     context: EditableText-[LabeledGlobalKey<EditableTextState>#f00bc]
      │ │
      │ └─Child 2: FocusNode#bdb17(FocusTraversalGroup [IN FOCUS PATH])
      │   │ context: Focus
      │   │ NOT FOCUSABLE
      │   │ IN FOCUS PATH
      │   │
      │   ├─Child 1: FocusNode#1b886([PRIMARY FOCUS])
      │   │   context: EditableText-[LabeledGlobalKey<EditableTextState>#c5a56]
      │   │   PRIMARY FOCUS
      │   │
      │   ├─Child 2: FocusNode#61cd5
      │   │   context: EditableText-[LabeledGlobalKey<EditableTextState>#a4dd8]
      │   │
      │   └─Child 3: FocusNode#02fac
      │       context: EditableText-[LabeledGlobalKey<EditableTextState>#fe12d]
      │
      ├─Child 2: FocusNode#4886e
      │   context: Focus
      │
      └─Child 3: FocusNode#9241b
          context: Focus