Unverified Commit d2fe0862 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Implements FocusTraversalPolicy and DefaultFocusTraversal features. (#30076)

This implements a DefaultFocusTraversal widget to describe the focus traversal policy for its children, defined by a FocusTraversalPolicy object from which custom policies may be created. Pre-defined policies include widget-order traversal, "reading order" traversal and directional traversal.
parent 9c77e8e8
......@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart';
import 'banner.dart';
import 'basic.dart';
import 'binding.dart';
import 'focus_traversal.dart';
import 'framework.dart';
import 'localizations.dart';
import 'media_query.dart';
......@@ -1190,12 +1191,15 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
assert(_debugCheckLocalizations(appLocale));
return MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
child: Localizations(
locale: appLocale,
delegates: _localizationsDelegates.toList(),
child: title,
return DefaultFocusTraversal(
policy: ReadingOrderTraversalPolicy(),
child: MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
child: Localizations(
locale: appLocale,
delegates: _localizationsDelegates.toList(),
child: title,
),
),
);
}
......
......@@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'binding.dart';
import 'focus_scope.dart';
import 'focus_traversal.dart';
import 'framework.dart';
/// Signature of a callback used by [Focus.onKey] and [FocusScope.onKey]
......@@ -191,6 +192,30 @@ class FocusAttachment {
/// [FocusManager.rootScope], the event is discarded.
/// {@endtemplate}
///
/// ## Focus Traversal
///
/// The term _traversal_, sometimes called _tab traversal_, refers to moving the
/// focus from one widget to the next in a particular order (also sometimes
/// referred to as the _tab order_, since the TAB key is often bound to the
/// action to move to the next widget).
///
/// To give focus to the logical _next_ or _previous_ widget in the UI, call the
/// [nextFocus] or [previousFocus] methods. To give the focus to a widget in a
/// particular direction, call the [focusInDirection] method.
///
/// The policy for what the _next_ or _previous_ widget is, or the widget in a
/// particular direction, is determined by the [FocusTraversalPolicy] in force.
///
/// The ambient policy is determined by looking up the widget hierarchy for a
/// [DefaultFocusTraversal] widget, and obtaining the focus traversal policy
/// from it. Different focus nodes can inherit difference policies, so part of
/// the app can go in widget order, and part can go in reading order, depending
/// upon the use case.
///
/// Predefined policies include [WidgetOrderFocusTraversalPolicy],
/// [ReadingOrderTraversalPolicy], and [DirectionalFocusTraversalPolicyMixin],
/// but custom policies can be built based upon these policies.
///
/// {@tool snippet --template=stateless_widget_scaffold}
/// This example shows how a FocusNode should be managed if not using the
/// [Focus] or [FocusScope] widgets. See the [Focus] widget for a similar
......@@ -304,6 +329,10 @@ class FocusAttachment {
/// widget tree.
/// * [FocusManager], a singleton that manages the focus and distributes key
/// events to focused nodes.
/// * [FocusTraversalPolicy], a class used to determine how to move the focus
/// to other nodes.
/// * [DefaultFocusTraversal], a widget used to configure the default focus
/// traversal policy for a widget subtree.
class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// Creates a focus node.
///
......@@ -584,6 +613,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
}
assert(_manager == null || child != _manager.rootScope, "Reparenting the root node isn't allowed.");
assert(!ancestors.contains(child), 'The supplied child is already an ancestor of this node. Loops are not allowed.');
final FocusScopeNode oldScope = child.enclosingScope;
final bool hadFocus = child.hasFocus;
child._parent?._removeChild(child);
_children.add(child);
......@@ -593,6 +623,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
// Update the focus chain for the current focus without changing it.
_manager?._currentFocus?._setAsFocusedChild();
}
if (oldScope != null && child.context != null && child.enclosingScope != oldScope) {
DefaultFocusTraversal.of(child.context, nullOk: true)?.changedScope(node: child, oldScope: oldScope);
}
}
/// Called by the _host_ [StatefulWidget] to attach a [FocusNode] to the
......@@ -655,15 +688,14 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
}
assert(node.ancestors.contains(this),
'Focus was requested for a node that is not a descendant of the scope from which it was requested.');
node._doRequestFocus(isFromPolicy: false);
node._doRequestFocus();
return;
}
_doRequestFocus(isFromPolicy: false);
_doRequestFocus();
}
// Note that this is overridden in FocusScopeNode.
void _doRequestFocus({@required bool isFromPolicy}) {
assert(isFromPolicy != null);
void _doRequestFocus() {
_setAsFocusedChild();
if (hasPrimaryFocus) {
return;
......@@ -691,6 +723,24 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
}
}
/// 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() => DefaultFocusTraversal.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() => DefaultFocusTraversal.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) => DefaultFocusTraversal.of(context).inDirection(this, direction);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
......@@ -782,7 +832,7 @@ class FocusScopeNode extends FocusNode {
}
assert(scope.ancestors.contains(this), '$FocusScopeNode $scope must be a child of $this to set it as first focus.');
if (hasFocus) {
scope._doRequestFocus(isFromPolicy: false);
scope._doRequestFocus();
} else {
scope._setAsFocusedChild();
}
......@@ -805,13 +855,12 @@ class FocusScopeNode extends FocusNode {
}
assert(node.ancestors.contains(this),
'Autofocus was requested for a node that is not a descendant of the scope from which it was requested.');
node._doRequestFocus(isFromPolicy: false);
node._doRequestFocus();
}
}
@override
void _doRequestFocus({@required bool isFromPolicy}) {
assert(isFromPolicy != null);
void _doRequestFocus() {
// Start with the primary focus as the focused child of this scope, if there
// is one. Otherwise start with this node itself.
FocusNode primaryFocus = focusedChild ?? this;
......@@ -827,6 +876,9 @@ class FocusScopeNode extends FocusNode {
_setAsFocusedChild();
_markAsDirty(newFocus: primaryFocus);
} else {
// We found a FocusScope at the leaf, so ask it to focus itself instead of
// this scope. That will cause this scope to return true from hasFocus,
// but false from hasPrimaryFocus.
primaryFocus.requestFocus();
}
}
......@@ -956,6 +1008,8 @@ class FocusManager with DiagnosticableTreeMixin {
_haveScheduledUpdate = false;
final FocusNode previousFocus = _currentFocus;
if (_currentFocus == null && _nextFocus == null) {
// If we don't have any current focus, and nobody has asked to focus yet,
// then pick a first one using widget order as a default.
_nextFocus = rootScope;
}
if (_nextFocus != null && _nextFocus != _currentFocus) {
......
......@@ -132,6 +132,10 @@ import 'inherited_notifier.dart';
/// traversal.
/// * [FocusManager], a singleton that manages the primary focus and
/// distributes key events to focused nodes.
/// * [FocusTraversalPolicy], an object used to determine how to move the
/// focus to other nodes.
/// * [DefaultFocusTraversal], a widget used to configure the default focus
/// traversal policy for a widget subtree.
class Focus extends StatefulWidget {
/// Creates a widget that manages a [FocusNode].
///
......@@ -375,6 +379,20 @@ class _FocusState extends State<Focus> {
/// more information about the details of what node management entails if not
/// using a [FocusScope] widget.
///
/// A [DefaultTraversalPolicy] widget provides the [FocusTraversalPolicy] for
/// the [FocusScopeNode]s owned by its descendant widgets. Each [FocusScopeNode]
/// has [FocusNode] descendants. The traversal policy defines what "previous
/// focus", "next focus", and "move focus in this direction" means for them.
///
/// [FocusScopeNode]s remember the last [FocusNode] that was focused within
/// their descendants, and can move that focus to the next/previous node, or a
/// node in a particular direction when the [FocusNode.nextFocus],
/// [FocusNode.previousFocus], or [FocusNode.focusInDirection] are called on a
/// [FocusNode] or [FocusScopeNode].
///
/// To move the focus, use methods on [FocusScopeNode]. For instance, to move
/// the focus to the next node, call `Focus.of(context).nextFocus()`.
///
/// See also:
///
/// * [FocusScopeNode], which represents a scope node in the focus hierarchy.
......@@ -384,6 +402,10 @@ class _FocusState extends State<Focus> {
/// managing focus without having to manage the node.
/// * [FocusManager], a singleton that manages the focus and distributes key
/// events to focused nodes.
/// * [FocusTraversalPolicy], an object used to determine how to move the
/// focus to other nodes.
/// * [DefaultFocusTraversal], a widget used to configure the default focus
/// traversal policy for a widget subtree.
class FocusScope extends Focus {
/// Creates a widget that manages a [FocusScopeNode].
///
......
This diff is collapsed.
......@@ -35,6 +35,7 @@ export 'src/widgets/editable_text.dart';
export 'src/widgets/fade_in_image.dart';
export 'src/widgets/focus_manager.dart';
export 'src/widgets/focus_scope.dart';
export 'src/widgets/focus_traversal.dart';
export 'src/widgets/form.dart';
export 'src/widgets/framework.dart';
export 'src/widgets/gesture_detector.dart';
......
......@@ -4057,7 +4057,7 @@ void main() {
});
testWidgets('TextField loses focus when disabled', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode();
final FocusNode focusNode = FocusNode(debugLabel: 'TextField Focus Node');
await tester.pumpWidget(
boilerplate(
......
......@@ -744,6 +744,7 @@ void main() {
TextEditingController currentController = controller1;
StateSetter setState;
final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Focus Node');
Widget builder() {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
......@@ -757,7 +758,7 @@ void main() {
child: EditableText(
backgroundCursorColor: Colors.grey,
controller: currentController,
focusNode: FocusNode(),
focusNode: focusNode,
style: Typography(platform: TargetPlatform.android)
.black
.subhead,
......@@ -775,6 +776,8 @@ void main() {
}
await tester.pumpWidget(builder());
await tester.pump(); // An extra pump to allow focus request to go through.
await tester.showKeyboard(find.byType(EditableText));
// Verify TextInput.setEditingState is fired with updated text when controller is replaced.
......
......@@ -596,34 +596,36 @@ void main() {
// This checks both FocusScopes that have their own nodes, as well as those
// that use external nodes.
await tester.pumpWidget(
Column(
children: <Widget>[
FocusScope(
key: scopeKeyA,
node: parentFocusScope,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child A',
key: keyA,
name: 'a',
),
],
DefaultFocusTraversal(
child: Column(
children: <Widget>[
FocusScope(
key: scopeKeyA,
node: parentFocusScope,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child A',
key: keyA,
name: 'a',
),
],
),
),
),
FocusScope(
key: scopeKeyB,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child B',
key: keyB,
name: 'b',
),
],
FocusScope(
key: scopeKeyB,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child B',
key: keyB,
name: 'b',
),
],
),
),
),
],
],
),
),
);
......@@ -647,7 +649,93 @@ void main() {
expect(WidgetsBinding.instance.focusManager.rootScope.children, isEmpty);
});
// Arguably, this isn't correct behavior, but it is what happens now.
// By "pinned", it means kept in the tree by a GlobalKey.
testWidgets("Removing pinned focused scope doesn't move focus to focused widget within next FocusScope", (WidgetTester tester) async {
final GlobalKey<TestFocusState> keyA = GlobalKey();
final GlobalKey<TestFocusState> keyB = GlobalKey();
final GlobalKey<TestFocusState> scopeKeyA = GlobalKey();
final GlobalKey<TestFocusState> scopeKeyB = GlobalKey();
final FocusScopeNode parentFocusScope1 = FocusScopeNode(debugLabel: 'Parent Scope 1');
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
await tester.pumpWidget(
DefaultFocusTraversal(
child: Column(
children: <Widget>[
FocusScope(
key: scopeKeyA,
node: parentFocusScope1,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child A',
key: keyA,
name: 'a',
),
],
),
),
FocusScope(
key: scopeKeyB,
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child B',
key: keyB,
name: 'b',
),
],
),
),
],
),
),
);
FocusScope.of(keyB.currentContext).requestFocus(keyB.currentState.focusNode);
FocusScope.of(keyA.currentContext).requestFocus(keyA.currentState.focusNode);
final FocusScopeNode bScope = FocusScope.of(keyB.currentContext);
final FocusScopeNode aScope = FocusScope.of(keyA.currentContext);
WidgetsBinding.instance.focusManager.rootScope.setFirstFocus(bScope);
WidgetsBinding.instance.focusManager.rootScope.setFirstFocus(aScope);
await tester.pumpAndSettle();
expect(FocusScope.of(keyA.currentContext).isFirstFocus, isTrue);
expect(keyA.currentState.focusNode.hasFocus, isTrue);
expect(find.text('A FOCUSED'), findsOneWidget);
expect(keyB.currentState.focusNode.hasFocus, isFalse);
expect(find.text('b'), findsOneWidget);
await tester.pumpWidget(
DefaultFocusTraversal(
child: Column(
children: <Widget>[
FocusScope(
key: scopeKeyB,
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
key: keyB,
name: 'b',
autofocus: true,
),
],
),
),
],
),
),
);
await tester.pump();
expect(keyB.currentState.focusNode.hasFocus, isFalse);
expect(find.text('b'), findsOneWidget);
});
testWidgets("Removing unpinned focused scope doesn't move focus to focused widget within next FocusScope", (WidgetTester tester) async {
final GlobalKey<TestFocusState> keyA = GlobalKey();
final GlobalKey<TestFocusState> keyB = GlobalKey();
......@@ -655,33 +743,35 @@ void main() {
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
await tester.pumpWidget(
Column(
children: <Widget>[
FocusScope(
node: parentFocusScope1,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child A',
key: keyA,
name: 'a',
),
],
DefaultFocusTraversal(
child: Column(
children: <Widget>[
FocusScope(
node: parentFocusScope1,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child A',
key: keyA,
name: 'a',
),
],
),
),
),
FocusScope(
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child B',
key: keyB,
name: 'b',
),
],
FocusScope(
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
debugLabel: 'Child B',
key: keyB,
name: 'b',
),
],
),
),
),
],
],
),
),
);
......@@ -700,26 +790,24 @@ void main() {
expect(keyB.currentState.focusNode.hasFocus, isFalse);
expect(find.text('b'), findsOneWidget);
// If the FocusScope widgets are not pinned with GlobalKeys, then the first
// one remains and gets its guts replaced with the parentFocusScope2 and the
// "B" test widget, and in the process, the focus manager loses track of the
// focus.
await tester.pumpWidget(
Column(
children: <Widget>[
FocusScope(
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
key: keyB,
name: 'b',
autofocus: true,
),
],
DefaultFocusTraversal(
child: Column(
children: <Widget>[
FocusScope(
node: parentFocusScope2,
child: Column(
children: <Widget>[
TestFocus(
key: keyB,
name: 'b',
autofocus: true,
),
],
),
),
),
],
],
),
),
);
await tester.pump();
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment