Focus in Flutter: Tree, Accessibility and Keyboard
Focus is a key system in Flutter that manages which element is active and directs keyboard input accordingly. It also enables easy navigation between elements, ensuring smooth tab traversal for better accessibility. In this article we will understand how all these works together.
Outline
Focus Tree
Focus nodes form a tree that sparsely mirrors the widget tree, representing all elements that can receive focus.
Additionally, there is the FocusScopeNode, that manages a stack of focused children, where the topmost element (i.e. last item in the array) is currently focused one. FocusScopeNode is automatically created in the root of the tree and called rootScope.
Other nodes are added to the tree via Focus and FocusScope widgets accordingly. During instantiation, they attach their BuildContext to the node, so that the node can find the correct ancestor to be reparented to (see reparent). When the widget is disposed, it calls detach method that removes the node from the tree.
Focus Manager
Focus manager is responsible for managing the focus tree, with the following responsibilities:
- Keeping track of the current primary focus
- Holding the root scope of the focus tree
- Distributing keyboard events among nodes
Primary Focus
A primarily focused node is one whose descendants are unfocused while its ancestors are focused. At the same time, the hasFocus getter on FocusNode returns true if this node or any of its descendants has primaryFocus. See:
- FocusScope (hasFocus=true)
- Focus (hasFocus=false)
- FocusScope (hasFocus=true)
- Focus (primaryFocus=true, hasFocus=true)
- Focus (hasFocus=false)Primary focus and has focus distribution
The focus manager keeps track of the primary focus. When the focus moves to another item in the tree, the focus manager schedules an update to that item so that the primary focus is updated to a new value in the next microtask.
Primary focus also makes it possible to correctly distribute keyboard events to nodes. FocusManager registers a key event handler that is fired each time the user presses a key on the keyboard.
When the callback is fired, the focus manager traverses the focus tree from the primary focus up through its ancestors (basically from anywhere in the tree to the root) until it finds a FocusNode that is interested in this event:
bool handleKeyMessage(KeyMessage message) {
// ... some code removed
// Walk the current focus from the leaf to the root, calling each node's
// onKeyEvent on the way up, and if one responds that they handled it or
// want to stop propagation, stop.
for (final FocusNode node in <FocusNode>[
FocusManager.instance.primaryFocus!,
...FocusManager.instance.primaryFocus!.ancestors,
]) {
final List<KeyEventResult> results = <KeyEventResult>[];
if (node.onKeyEvent != null) {
for (final KeyEvent event in message.events) {
results.add(node.onKeyEvent!(node, event));
}
}
if (node.onKey != null && message.rawEvent != null) {
results.add(node.onKey!(node, message.rawEvent!));
}
final KeyEventResult result = combineKeyEventResults(results);
switch (result) {
case KeyEventResult.ignored:
continue;
case KeyEventResult.handled:
assert(_focusDebug(() => 'Node $node handled key event $message.'));
handled = true;
case KeyEventResult.skipRemainingHandlers:
assert(_focusDebug(() => 'Node $node stopped key event propagation: $message.'));
handled = false;
}
// Only KeyEventResult.ignored will continue the for loop. All other
// options will stop the event propagation.
assert(result != KeyEventResult.ignored);
break;
}
// ... some code removed
return handled;
}This is how to specify the onKeyEvent:
Focus(
onKeyEvent: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.enter) {
print('Enter key pressed');
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: SizedBox.expand(),
)Important note: If a FocusNode is attached to a Focus or FocusScope, specify the onKeyEvent directly in the widget, not in the node, as it will be overwritten otherwise.
Shortcuts
Flutter includes a built-in Shortcuts widget that simplifies creating key bindings. For example, the following code demonstrates two key bindings for the up and down arrow keys:
Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
LogicalKeySet(LogicalKeyboardKey.arrowUp): const IncrementIntent(2),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DecrementIntent(2),
},
child: Actions(
actions: <Type, Action<Intent>>{
IncrementIntent: IncrementAction(model),
DecrementIntent: DecrementAction(model),
},
child: Focus(
autofocus: true,
child: Column(
children: <Widget>[
const Text('Add to the counter by pressing the up arrow key'),
const Text(
'Subtract from the counter by pressing the down arrow key'),
ListenableBuilder(
listenable: model,
builder: (BuildContext context, Widget? child) {
return Text('count: ${model.count}');
},
),
],
),
),
),
);Under the hood, the Shortcuts widget creates a Focus widget with the provided onKeyEvent. This callback is then passed to the FocusNode and invoked each time the user presses a key.
Can you guess why an additional Focus widget with autofocus: true is added in the example above? This is necessary to make this Focus widget the primary focus. This way, the Focus widget created by the Shortcuts will be able to receive the events.
Focus Traversal
The focus system also enables focus traversal, a feature that allows moving to the next focus node or returning to the previous one. This functionality is crucial for accessibility.
WidgetsApp (parent for Material and Cupertino apps) provides default shortcuts for the app:
static const Map<ShortcutActivator, Intent> _defaultShortcuts = <ShortcutActivator, Intent>{
// Activation
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.select): ActivateIntent(),
// Dismissal
SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
// Keyboard traversal.
SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(),
SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(),
SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left),
SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right),
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
// Scrolling
SingleActivator(LogicalKeyboardKey.arrowUp, control: true): ScrollIntent(direction: AxisDirection.up),
SingleActivator(LogicalKeyboardKey.arrowDown, control: true): ScrollIntent(direction: AxisDirection.down),
SingleActivator(LogicalKeyboardKey.arrowLeft, control: true): ScrollIntent(direction: AxisDirection.left),
SingleActivator(LogicalKeyboardKey.arrowRight, control: true): ScrollIntent(direction: AxisDirection.right),
SingleActivator(LogicalKeyboardKey.pageUp): ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page),
SingleActivator(LogicalKeyboardKey.pageDown): ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page),
};Among them, there are a few for keyboard traversal. We will focus on the next and previous focus intens today. Their corresponding actions (action is a reaction to the intent) are pretty straightforward:
class NextFocusAction extends Action<NextFocusIntent> {
/// Attempts to pass the focus to the next widget.
///
/// Returns true if a widget was focused as a result of invoking this action.
///
/// Returns false when the traversal reached the end and the engine must pass
/// focus to platform UI.
@override
bool invoke(NextFocusIntent intent) {
return primaryFocus!.nextFocus();
}
@override
KeyEventResult toKeyEventResult(NextFocusIntent intent, bool invokeResult) {
return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
}
}
class PreviousFocusAction extends Action<PreviousFocusIntent> {
/// Attempts to pass the focus to the previous widget.
///
/// Returns true if a widget was focused as a result of invoking this action.
///
/// Returns false when the traversal reached the beginning and the engine must
/// pass focus to platform UI.
@override
bool invoke(PreviousFocusIntent intent) {
return primaryFocus!.previousFocus();
}
@override
KeyEventResult toKeyEventResult(PreviousFocusIntent intent, bool invokeResult) {
return invokeResult ? KeyEventResult.handled : KeyEventResult.skipRemainingHandlers;
}
}What these actions do is call nextFocus or previousFocus on the primaryNode. Internally, these methods utilize FocusTraversalGroup and its corresponding methods:
/// Request to move the focus to the next focus node, by calling the
/// [FocusTraversalPolicy.next] method.
///
/// Returns true if it successfully found a node and requested focus.
bool nextFocus() => FocusTraversalGroup.of(context!).next(this);
/// Request to move the focus to the previous focus node, by calling the
/// [FocusTraversalPolicy.previous] method.
///
/// Returns true if it successfully found a node and requested focus.
bool previousFocus() => FocusTraversalGroup.of(context!).previous(this);
/// Request to move the focus to the nearest focus node in the given
/// direction, by calling the [FocusTraversalPolicy.inDirection] method.
///
/// Returns true if it successfully found a node and requested focus.
bool focusInDirection(TraversalDirection direction) => FocusTraversalGroup.of(context!).inDirection(this, direction);Focus traversal group

FocusTraversalGroup is a widget that places its subtree into a group, thus enabling the subtree to be traversed collectively in accordance with a specified policy.
FocusTraversalPolicy is a unit that determines how focusable widgets are traversed in FocusTraversalGroup. There are a few implementations:
- WidgetOrderTraversalPolicy, a policy that relies on the widget creation order to describe the order of traversal.
- ReadingOrderTraversalPolicy, a policy that describes the order as the natural "reading order" for the current Directionality.
- OrderedTraversalPolicy, a policy that describes the order explicitly using FocusTraversalOrder widgets.
- DirectionalFocusTraversalPolicyMixin a mixin class that implements focus traversal in a direction.
In order to implement your own traversal policy you can extend FocusTraversalPolicy and implement sortAllDescendants method.
Let's see how the traversal group works with OrderedTraversalPolicy:
Scaffold(
body: FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: const Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
FocusTraversalOrder(
order: NumericFocusOrder(1),
child: TextField(),
),
FocusTraversalOrder(
order: NumericFocusOrder(3),
child: TextField(),
),
FocusTraversalOrder(
order: NumericFocusOrder(2),
child: TextField(),
),
FocusTraversalOrder(
order: NumericFocusOrder(5),
child: TextField(),
),
FocusTraversalOrder(
order: NumericFocusOrder(4),
child: TextField(),
),
],
),
),
),
)See how it looks like:
FocusTraversalGroup creates a Focus widget and assigns it a custom node containing the specified policy. This policy is used during traversal to arrange the focus nodes in the correct order.
When the user presses TAB, the event notification is sent to the Shortcuts, which are defined in the root of the widget tree in WidgetsApp. This triggers a NextFocusIntent, causing primaryFocus.nextFocus to be invoked. This method locates a FocusTraversalGroup above the current node in the focus tree and calls its next method.
The _moveFocus method of FocusTraversalGroup is called to find the next node that should receive the focus:
bool _moveFocus(FocusNode currentNode, {required bool forward}) {
// ...
final List<FocusNode> sortedNodes = _sortAllDescendants(nearestScope, focusedChild);
// ...
final Iterable<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
FocusNode? previousNode;
for (final FocusNode node in maybeFlipped) {
if (previousNode == focusedChild) {
return _requestTabTraversalFocus(
node,
alignmentPolicy: forward ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
forward: forward,
);
}
previousNode = node;
}
return false;
}It retrieves the nodes from sortAllDescendants, excluding those that can't be traversed or focused due to their settings. It then iterates through this list to find the next eligible node to focus on, which triggers the FocusNode.requestFocus method.
Additionally, it calls Scrollable.ensureVisible with the context attached to the FocusNode, ensuring that if the item is within a Scrollable (such as a list view), it will become visible. This schedules an update in FocusManager that moves the primary focus to an appropriate node.