In Flutter, how do FocusTraversalGroup and OrderedTraversalPolicy fix a Tab order that the default policy gets wrong?
answer
- Tab maps to NextFocusIntent
- default: ReadingOrderTraversalPolicy
- a group is sorted as one unit
- FocusTraversalOrder with NumericFocusOrder
- unordered nodes come after ordered ones
basics
~10 sTab order comes from the nearest FocusTraversalGroup's policy, by default ReadingOrderTraversalPolicy, which sorts by on-screen position. Wrap regions in their own FocusTraversalGroup, or use OrderedTraversalPolicy with FocusTraversalOrder(order: NumericFocusOrder(n)) for an explicit order.
solid answer
~40 s`WidgetsApp` maps Tab and Shift+Tab to `NextFocusIntent` and `PreviousFocusIntent`, whose actions ask the nearest `FocusTraversalGroup`'s policy for the next node. The default policy is `ReadingOrderTraversalPolicy`: it sorts focusable nodes by their rectangles, row by row in the ambient `Directionality`, so a two-column form is traversed across each row instead of down each column. Wrapping each column in its own `FocusTraversalGroup` fixes that, because a group is sorted as one unit by its parent's policy and its members by its own. For an explicit order, give a group `OrderedTraversalPolicy()` and wrap children in `FocusTraversalOrder(order: NumericFocusOrder(1), ...)` or `LexicalFocusOrder('a')`; children without an order come after the ordered ones, sorted by the `secondary` policy (reading order by default). `skipTraversal`, `ExcludeFocus` and `descendantsAreTraversable: false` remove nodes from Tab order.
code
dart · 28 linesimport 'package:flutter/material.dart';
class TenderButtons extends StatelessWidget {
const TenderButtons({super.key, required this.onCard, required this.onCash});
final VoidCallback onCard;
final VoidCallback onCash;
@override
Widget build(BuildContext context) {
// Visually Cash sits left of Card, but Tab should reach Card first.
return FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Row(
children: <Widget>[
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextButton(onPressed: onCash, child: const Text('Cash')),
),
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextButton(onPressed: onCard, child: const Text('Card')),
),
],
),
);
}
}go deeper
Recall that Tab follows the screen in reading order by default, and that FocusTraversalGroup is the widget you reach for when it is wrong.
Explain how groups are sorted as one unit, how OrderedTraversalPolicy uses FocusTraversalOrder, and where unordered widgets land.
Fix a real multi-column or keypad layout with groups before numbers, and debug the order with debugLabel and debugDumpFocusTree.
Set a convention that layout-driven groups are the default and explicit numeric orders are the exception, so orders survive redesigns.
## How Tab moves focus Keyboard traversal in Flutter is itself built on shortcuts. `WidgetsApp` (and so `MaterialApp` and `CupertinoApp`) installs `WidgetsApp.defaultShortcuts`, which map: - **Tab** to `NextFocusIntent`, - **Shift+Tab** to `PreviousFocusIntent`, - the arrow keys to `DirectionalFocusIntent` outside the web, where they scroll instead. The matching actions call `nextFocus()`, `previousFocus()` or `focusInDirection()` on the primary focus node, and those delegate to a **`FocusTraversalPolicy`** found through the nearest `FocusTraversalGroup` above the node. The policy decides the order; the focus tree decides what is focusable. ## The default: reading order When no group sets a policy, Flutter uses `ReadingOrderTraversalPolicy` — `FocusTraversalGroup`'s `policy` parameter defaults to it. Its algorithm, from the API docs: 1. Find the node whose rectangle has the highest top edge. 2. Collect every node that intersects the horizontal band defined by that rectangle. 3. Pick the one closest to the start of the reading direction, taken from the ambient `Directionality`. This matches how a person reads a page, and it is right for most screens. It goes wrong when the **visual grouping is by column**: a two-column checkout panel, a sidebar next to a form, or a keypad beside a tender list. Reading order visits row 1 left, row 1 right, row 2 left — jumping between columns on every Tab. ## Groups: sort a region as one unit A `FocusTraversalGroup` does two things: - **Inside** the group, its own `policy` orders the members. - **Outside**, the parent group's policy treats the whole group as **one entity** to be sorted among its siblings. So wrapping each column in its own `FocusTraversalGroup` makes Tab finish the left column before entering the right one, without assigning any numbers. Groups also carry `descendantsAreFocusable` and `descendantsAreTraversable` to switch a whole region off — for instance a collapsed side panel. ## Explicit order: OrderedTraversalPolicy When the order is not derivable from layout, give the group `OrderedTraversalPolicy()` and annotate children: | Piece | Role | |---|---| | `FocusTraversalOrder(order: ..., child: ...)` | an inherited widget attaching an order to a subtree | | `NumericFocusOrder(2)` | compares by a `double`; lower comes first | | `LexicalFocusOrder('b')` | compares by a string | | `OrderedTraversalPolicy(secondary: ...)` | sorts annotated nodes; ties and unannotated nodes use `secondary`, which defaults to reading order | Two rules from the source are worth remembering: - Nodes **without** a `FocusTraversalOrder` are placed **after** all ordered nodes, sorted by the secondary policy. - All orders compared in one group must be the **same type**. Mixing `NumericFocusOrder` and `LexicalFocusOrder` in one group trips an assertion that tells you to split them into separate groups. `WidgetOrderTraversalPolicy` is the third built-in policy: it follows widget-tree order, ignoring geometry, which suits generated forms whose tree order is already correct. ## Taking nodes out of Tab order - `Focus(skipTraversal: true)` — the node can still be focused by tap or `requestFocus()`, but Tab skips it. - `ExcludeFocus(child: ...)` — every descendant becomes unfocusable. - `Focus(canRequestFocus: false)` — the node itself refuses focus while its descendants remain focusable. - `FocusTraversalGroup(descendantsAreTraversable: false)` — descendants stay focusable by tap but leave Tab order. ## Debugging an order - Give nodes `debugLabel`s and print `debugDumpFocusTree()` to see the focus tree and which scope and group each node sits in. - Check `Directionality`: an RTL locale reverses reading order, which is correct behaviour, not a bug. - If Tab stops moving at all, check that the primary focus is not on the root scope, which happens when a node is unfocused with no scope between it and the root.
- In Flutter, what is the difference between skipTraversal and ExcludeFocus?`skipTraversal: true` on a `Focus` or `FocusNode` keeps the node focusable by tap or `requestFocus()` but removes it from Tab and arrow-key traversal. `ExcludeFocus` makes every descendant unfocusable, so they cannot receive focus by any route. Use the first for a control users click but rarely tab to, the second for a disabled or offscreen region.
- Why can a FocusTraversalGroup fix a two-column Tab order without assigning any numbers?The parent policy sorts each group as a single entity by its position, and only then does the group's own policy order its members. Two column groups side by side are visited left group first, and inside each group reading order goes top to bottom, so Tab finishes a column before moving to the next.
saying these in an interview costs you the question
- Flutter's default Tab order is the order widgets appear in the tree
- Widgets without a FocusTraversalOrder are skipped by OrderedTraversalPolicy
- NumericFocusOrder and LexicalFocusOrder can be freely mixed in one group
- skipTraversal makes a widget impossible to focus
- A FocusTraversalGroup changes only its children, not where it sits among siblings