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'; ...@@ -11,6 +11,7 @@ import 'package:flutter/rendering.dart';
import 'banner.dart'; import 'banner.dart';
import 'basic.dart'; import 'basic.dart';
import 'binding.dart'; import 'binding.dart';
import 'focus_traversal.dart';
import 'framework.dart'; import 'framework.dart';
import 'localizations.dart'; import 'localizations.dart';
import 'media_query.dart'; import 'media_query.dart';
...@@ -1190,12 +1191,15 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv ...@@ -1190,12 +1191,15 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
assert(_debugCheckLocalizations(appLocale)); assert(_debugCheckLocalizations(appLocale));
return MediaQuery( return DefaultFocusTraversal(
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window), policy: ReadingOrderTraversalPolicy(),
child: Localizations( child: MediaQuery(
locale: appLocale, data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
delegates: _localizationsDelegates.toList(), child: Localizations(
child: title, locale: appLocale,
delegates: _localizationsDelegates.toList(),
child: title,
),
), ),
); );
} }
......
...@@ -11,6 +11,7 @@ import 'package:flutter/services.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'binding.dart'; import 'binding.dart';
import 'focus_scope.dart'; import 'focus_scope.dart';
import 'focus_traversal.dart';
import 'framework.dart'; import 'framework.dart';
/// Signature of a callback used by [Focus.onKey] and [FocusScope.onKey] /// Signature of a callback used by [Focus.onKey] and [FocusScope.onKey]
...@@ -191,6 +192,30 @@ class FocusAttachment { ...@@ -191,6 +192,30 @@ class FocusAttachment {
/// [FocusManager.rootScope], the event is discarded. /// [FocusManager.rootScope], the event is discarded.
/// {@endtemplate} /// {@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} /// {@tool snippet --template=stateless_widget_scaffold}
/// This example shows how a FocusNode should be managed if not using the /// This example shows how a FocusNode should be managed if not using the
/// [Focus] or [FocusScope] widgets. See the [Focus] widget for a similar /// [Focus] or [FocusScope] widgets. See the [Focus] widget for a similar
...@@ -304,6 +329,10 @@ class FocusAttachment { ...@@ -304,6 +329,10 @@ class FocusAttachment {
/// widget tree. /// widget tree.
/// * [FocusManager], a singleton that manages the focus and distributes key /// * [FocusManager], a singleton that manages the focus and distributes key
/// events to focused nodes. /// 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 { class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// Creates a focus node. /// Creates a focus node.
/// ///
...@@ -584,6 +613,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -584,6 +613,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
} }
assert(_manager == null || child != _manager.rootScope, "Reparenting the root node isn't allowed."); 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.'); 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; final bool hadFocus = child.hasFocus;
child._parent?._removeChild(child); child._parent?._removeChild(child);
_children.add(child); _children.add(child);
...@@ -593,6 +623,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -593,6 +623,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
// Update the focus chain for the current focus without changing it. // Update the focus chain for the current focus without changing it.
_manager?._currentFocus?._setAsFocusedChild(); _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 /// Called by the _host_ [StatefulWidget] to attach a [FocusNode] to the
...@@ -655,15 +688,14 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -655,15 +688,14 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
} }
assert(node.ancestors.contains(this), assert(node.ancestors.contains(this),
'Focus was requested for a node that is not a descendant of the scope from which it was requested.'); '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; return;
} }
_doRequestFocus(isFromPolicy: false); _doRequestFocus();
} }
// Note that this is overridden in FocusScopeNode. // Note that this is overridden in FocusScopeNode.
void _doRequestFocus({@required bool isFromPolicy}) { void _doRequestFocus() {
assert(isFromPolicy != null);
_setAsFocusedChild(); _setAsFocusedChild();
if (hasPrimaryFocus) { if (hasPrimaryFocus) {
return; return;
...@@ -691,6 +723,24 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -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 @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
...@@ -782,7 +832,7 @@ class FocusScopeNode extends FocusNode { ...@@ -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.'); assert(scope.ancestors.contains(this), '$FocusScopeNode $scope must be a child of $this to set it as first focus.');
if (hasFocus) { if (hasFocus) {
scope._doRequestFocus(isFromPolicy: false); scope._doRequestFocus();
} else { } else {
scope._setAsFocusedChild(); scope._setAsFocusedChild();
} }
...@@ -805,13 +855,12 @@ class FocusScopeNode extends FocusNode { ...@@ -805,13 +855,12 @@ class FocusScopeNode extends FocusNode {
} }
assert(node.ancestors.contains(this), assert(node.ancestors.contains(this),
'Autofocus was requested for a node that is not a descendant of the scope from which it was requested.'); '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 @override
void _doRequestFocus({@required bool isFromPolicy}) { void _doRequestFocus() {
assert(isFromPolicy != null);
// Start with the primary focus as the focused child of this scope, if there // Start with the primary focus as the focused child of this scope, if there
// is one. Otherwise start with this node itself. // is one. Otherwise start with this node itself.
FocusNode primaryFocus = focusedChild ?? this; FocusNode primaryFocus = focusedChild ?? this;
...@@ -827,6 +876,9 @@ class FocusScopeNode extends FocusNode { ...@@ -827,6 +876,9 @@ class FocusScopeNode extends FocusNode {
_setAsFocusedChild(); _setAsFocusedChild();
_markAsDirty(newFocus: primaryFocus); _markAsDirty(newFocus: primaryFocus);
} else { } 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(); primaryFocus.requestFocus();
} }
} }
...@@ -956,6 +1008,8 @@ class FocusManager with DiagnosticableTreeMixin { ...@@ -956,6 +1008,8 @@ class FocusManager with DiagnosticableTreeMixin {
_haveScheduledUpdate = false; _haveScheduledUpdate = false;
final FocusNode previousFocus = _currentFocus; final FocusNode previousFocus = _currentFocus;
if (_currentFocus == null && _nextFocus == null) { 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; _nextFocus = rootScope;
} }
if (_nextFocus != null && _nextFocus != _currentFocus) { if (_nextFocus != null && _nextFocus != _currentFocus) {
......
...@@ -132,6 +132,10 @@ import 'inherited_notifier.dart'; ...@@ -132,6 +132,10 @@ import 'inherited_notifier.dart';
/// traversal. /// traversal.
/// * [FocusManager], a singleton that manages the primary focus and /// * [FocusManager], a singleton that manages the primary focus and
/// distributes key events to focused nodes. /// 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 { class Focus extends StatefulWidget {
/// Creates a widget that manages a [FocusNode]. /// Creates a widget that manages a [FocusNode].
/// ///
...@@ -375,6 +379,20 @@ class _FocusState extends State<Focus> { ...@@ -375,6 +379,20 @@ class _FocusState extends State<Focus> {
/// more information about the details of what node management entails if not /// more information about the details of what node management entails if not
/// using a [FocusScope] widget. /// 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: /// See also:
/// ///
/// * [FocusScopeNode], which represents a scope node in the focus hierarchy. /// * [FocusScopeNode], which represents a scope node in the focus hierarchy.
...@@ -384,6 +402,10 @@ class _FocusState extends State<Focus> { ...@@ -384,6 +402,10 @@ class _FocusState extends State<Focus> {
/// managing focus without having to manage the node. /// managing focus without having to manage the node.
/// * [FocusManager], a singleton that manages the focus and distributes key /// * [FocusManager], a singleton that manages the focus and distributes key
/// events to focused nodes. /// 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 { class FocusScope extends Focus {
/// Creates a widget that manages a [FocusScopeNode]. /// Creates a widget that manages a [FocusScopeNode].
/// ///
......
This diff is collapsed.
...@@ -35,6 +35,7 @@ export 'src/widgets/editable_text.dart'; ...@@ -35,6 +35,7 @@ export 'src/widgets/editable_text.dart';
export 'src/widgets/fade_in_image.dart'; export 'src/widgets/fade_in_image.dart';
export 'src/widgets/focus_manager.dart'; export 'src/widgets/focus_manager.dart';
export 'src/widgets/focus_scope.dart'; export 'src/widgets/focus_scope.dart';
export 'src/widgets/focus_traversal.dart';
export 'src/widgets/form.dart'; export 'src/widgets/form.dart';
export 'src/widgets/framework.dart'; export 'src/widgets/framework.dart';
export 'src/widgets/gesture_detector.dart'; export 'src/widgets/gesture_detector.dart';
......
...@@ -4057,7 +4057,7 @@ void main() { ...@@ -4057,7 +4057,7 @@ void main() {
}); });
testWidgets('TextField loses focus when disabled', (WidgetTester tester) async { testWidgets('TextField loses focus when disabled', (WidgetTester tester) async {
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode(debugLabel: 'TextField Focus Node');
await tester.pumpWidget( await tester.pumpWidget(
boilerplate( boilerplate(
......
...@@ -744,6 +744,7 @@ void main() { ...@@ -744,6 +744,7 @@ void main() {
TextEditingController currentController = controller1; TextEditingController currentController = controller1;
StateSetter setState; StateSetter setState;
final FocusNode focusNode = FocusNode(debugLabel: 'EditableText Focus Node');
Widget builder() { Widget builder() {
return StatefulBuilder( return StatefulBuilder(
builder: (BuildContext context, StateSetter setter) { builder: (BuildContext context, StateSetter setter) {
...@@ -757,7 +758,7 @@ void main() { ...@@ -757,7 +758,7 @@ void main() {
child: EditableText( child: EditableText(
backgroundCursorColor: Colors.grey, backgroundCursorColor: Colors.grey,
controller: currentController, controller: currentController,
focusNode: FocusNode(), focusNode: focusNode,
style: Typography(platform: TargetPlatform.android) style: Typography(platform: TargetPlatform.android)
.black .black
.subhead, .subhead,
...@@ -775,6 +776,8 @@ void main() { ...@@ -775,6 +776,8 @@ void main() {
} }
await tester.pumpWidget(builder()); await tester.pumpWidget(builder());
await tester.pump(); // An extra pump to allow focus request to go through.
await tester.showKeyboard(find.byType(EditableText)); await tester.showKeyboard(find.byType(EditableText));
// Verify TextInput.setEditingState is fired with updated text when controller is replaced. // Verify TextInput.setEditingState is fired with updated text when controller is replaced.
......
...@@ -596,34 +596,36 @@ void main() { ...@@ -596,34 +596,36 @@ void main() {
// This checks both FocusScopes that have their own nodes, as well as those // This checks both FocusScopes that have their own nodes, as well as those
// that use external nodes. // that use external nodes.
await tester.pumpWidget( await tester.pumpWidget(
Column( DefaultFocusTraversal(
children: <Widget>[ child: Column(
FocusScope( children: <Widget>[
key: scopeKeyA, FocusScope(
node: parentFocusScope, key: scopeKeyA,
child: Column( node: parentFocusScope,
children: <Widget>[ child: Column(
TestFocus( children: <Widget>[
debugLabel: 'Child A', TestFocus(
key: keyA, debugLabel: 'Child A',
name: 'a', key: keyA,
), name: 'a',
], ),
],
),
), ),
), FocusScope(
FocusScope( key: scopeKeyB,
key: scopeKeyB, child: Column(
child: Column( children: <Widget>[
children: <Widget>[ TestFocus(
TestFocus( debugLabel: 'Child B',
debugLabel: 'Child B', key: keyB,
key: keyB, name: 'b',
name: 'b', ),
), ],
], ),
), ),
), ],
], ),
), ),
); );
...@@ -647,7 +649,93 @@ void main() { ...@@ -647,7 +649,93 @@ void main() {
expect(WidgetsBinding.instance.focusManager.rootScope.children, isEmpty); 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 { 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> keyA = GlobalKey();
final GlobalKey<TestFocusState> keyB = GlobalKey(); final GlobalKey<TestFocusState> keyB = GlobalKey();
...@@ -655,33 +743,35 @@ void main() { ...@@ -655,33 +743,35 @@ void main() {
final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2'); final FocusScopeNode parentFocusScope2 = FocusScopeNode(debugLabel: 'Parent Scope 2');
await tester.pumpWidget( await tester.pumpWidget(
Column( DefaultFocusTraversal(
children: <Widget>[ child: Column(
FocusScope( children: <Widget>[
node: parentFocusScope1, FocusScope(
child: Column( node: parentFocusScope1,
children: <Widget>[ child: Column(
TestFocus( children: <Widget>[
debugLabel: 'Child A', TestFocus(
key: keyA, debugLabel: 'Child A',
name: 'a', key: keyA,
), name: 'a',
], ),
],
),
), ),
), FocusScope(
FocusScope( node: parentFocusScope2,
node: parentFocusScope2, child: Column(
child: Column( children: <Widget>[
children: <Widget>[ TestFocus(
TestFocus( debugLabel: 'Child B',
debugLabel: 'Child B', key: keyB,
key: keyB, name: 'b',
name: 'b', ),
), ],
], ),
), ),
), ],
], ),
), ),
); );
...@@ -700,26 +790,24 @@ void main() { ...@@ -700,26 +790,24 @@ void main() {
expect(keyB.currentState.focusNode.hasFocus, isFalse); expect(keyB.currentState.focusNode.hasFocus, isFalse);
expect(find.text('b'), findsOneWidget); 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( await tester.pumpWidget(
Column( DefaultFocusTraversal(
children: <Widget>[ child: Column(
FocusScope( children: <Widget>[
node: parentFocusScope2, FocusScope(
child: Column( node: parentFocusScope2,
children: <Widget>[ child: Column(
TestFocus( children: <Widget>[
key: keyB, TestFocus(
name: 'b', key: keyB,
autofocus: true, name: 'b',
), autofocus: true,
], ),
],
),
), ),
), ],
], ),
), ),
); );
await tester.pump(); 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