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

Adds canRequestFocus toggle to FocusNode (#38704)

* Add an 'unfocusable' focus node to allow developers to indicate when they don't want a Focus widget to be active

* more unfocusable changes. not working.

* Switch to focusable attribute

* Rename to canRequestFocus

* Turn off debug output

* Update docs

* Removed unused import
parent 34c69265
...@@ -13,22 +13,56 @@ void main() { ...@@ -13,22 +13,56 @@ void main() {
)); ));
} }
class DemoButton extends StatelessWidget { class DemoButton extends StatefulWidget {
const DemoButton({this.name}); const DemoButton({this.name, this.canRequestFocus = true, this.autofocus = false});
final String name; final String name;
final bool canRequestFocus;
final bool autofocus;
@override
_DemoButtonState createState() => _DemoButtonState();
}
class _DemoButtonState extends State<DemoButton> {
FocusNode focusNode;
@override
void initState() {
super.initState();
focusNode = FocusNode(
debugLabel: widget.name,
canRequestFocus: widget.canRequestFocus,
);
}
@override
void dispose() {
focusNode?.dispose();
super.dispose();
}
@override
void didUpdateWidget(DemoButton oldWidget) {
super.didUpdateWidget(oldWidget);
focusNode.canRequestFocus = widget.canRequestFocus;
}
void _handleOnPressed() { void _handleOnPressed() {
print('Button $name pressed.'); focusNode.requestFocus();
print('Button ${widget.name} pressed.');
debugDumpFocusTree();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FlatButton( return FlatButton(
focusNode: focusNode,
autofocus: widget.autofocus,
focusColor: Colors.red, focusColor: Colors.red,
hoverColor: Colors.blue, hoverColor: Colors.blue,
onPressed: () => _handleOnPressed(), onPressed: () => _handleOnPressed(),
child: Text(name), child: Text(widget.name),
); );
} }
} }
...@@ -119,14 +153,20 @@ class _FocusDemoState extends State<FocusDemo> { ...@@ -119,14 +153,20 @@ class _FocusDemoState extends State<FocusDemo> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[ children: const <Widget>[
DemoButton(name: 'One'), DemoButton(
name: 'One',
autofocus: true,
),
], ],
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: const <Widget>[ children: const <Widget>[
DemoButton(name: 'Two'), DemoButton(name: 'Two'),
DemoButton(name: 'Three'), DemoButton(
name: 'Three',
canRequestFocus: false,
),
], ],
), ),
Row( Row(
......
...@@ -367,8 +367,12 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -367,8 +367,12 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
FocusNode({ FocusNode({
String debugLabel, String debugLabel,
FocusOnKeyCallback onKey, FocusOnKeyCallback onKey,
this.skipTraversal = false, bool skipTraversal = false,
bool canRequestFocus = true,
}) : assert(skipTraversal != null), }) : assert(skipTraversal != null),
assert(canRequestFocus != null),
_skipTraversal = skipTraversal,
_canRequestFocus = canRequestFocus,
_onKey = onKey { _onKey = onKey {
// Set it via the setter so that it does nothing on release builds. // Set it via the setter so that it does nothing on release builds.
this.debugLabel = debugLabel; this.debugLabel = debugLabel;
...@@ -380,7 +384,50 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -380,7 +384,50 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// This may be used to place nodes in the focus tree that may be focused, but /// This may be used to place nodes in the focus tree that may be focused, but
/// not traversed, allowing them to receive key events as part of the focus /// not traversed, allowing them to receive key events as part of the focus
/// chain, but not be traversed to via focus traversal. /// chain, but not be traversed to via focus traversal.
bool skipTraversal; ///
/// This is different from [canRequestFocus] because it only implies that the
/// node can't be reached via traversal, not that it can't be focused. It may
/// still be focused explicitly.
bool get skipTraversal => _skipTraversal;
bool _skipTraversal;
set skipTraversal(bool value) {
if (value != _skipTraversal) {
_skipTraversal = value;
_notify();
}
}
/// If true, this focus node may request the primary focus.
///
/// Defaults to true. Set to false if you want this node to do nothing when
/// [requestFocus] is called on it. Does not affect the children of this node,
/// and [hasFocus] can still return true if this node is the ancestor of a
/// node with primary focus.
///
/// This is different than [skipTraversal] because [skipTraversal] still
/// allows the node to be focused, just not traversed to via the
/// [FocusTraversalPolicy]
///
/// Setting [canRequestFocus] to false implies that the node will also be
/// skipped for traversal purposes.
///
/// See also:
///
/// - [DefaultFocusTraversal], a widget that sets the traversal policy for
/// its descendants.
/// - [FocusTraversalPolicy], a class that can be extended to describe a
/// traversal policy.
bool get canRequestFocus => _canRequestFocus;
bool _canRequestFocus;
set canRequestFocus(bool value) {
if (value != _canRequestFocus) {
_canRequestFocus = value;
if (!_canRequestFocus) {
unfocus();
}
_notify();
}
}
/// The context that was supplied to [attach]. /// The context that was supplied to [attach].
/// ///
...@@ -413,7 +460,11 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -413,7 +460,11 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
/// An iterator over the children that are allowed to be traversed by the /// An iterator over the children that are allowed to be traversed by the
/// [FocusTraversalPolicy]. /// [FocusTraversalPolicy].
Iterable<FocusNode> get traversalChildren => children.where((FocusNode node) => !node.skipTraversal); Iterable<FocusNode> get traversalChildren {
return children.where(
(FocusNode node) => !node.skipTraversal && node.canRequestFocus,
);
}
/// A debug label that is used for diagnostic output. /// A debug label that is used for diagnostic output.
/// ///
...@@ -440,7 +491,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -440,7 +491,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
} }
/// Returns all descendants which do not have the [skipTraversal] flag set. /// Returns all descendants which do not have the [skipTraversal] flag set.
Iterable<FocusNode> get traversalDescendants => descendants.where((FocusNode node) => !node.skipTraversal); Iterable<FocusNode> get traversalDescendants => descendants.where((FocusNode node) => !node.skipTraversal && node.canRequestFocus);
/// An [Iterable] over the ancestors of this node. /// An [Iterable] over the ancestors of this node.
/// ///
...@@ -733,8 +784,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -733,8 +784,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
if (node._parent == null) { if (node._parent == null) {
_reparent(node); _reparent(node);
} }
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(); node._doRequestFocus();
return; return;
} }
...@@ -743,6 +793,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -743,6 +793,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
// Note that this is overridden in FocusScopeNode. // Note that this is overridden in FocusScopeNode.
void _doRequestFocus() { void _doRequestFocus() {
if (!canRequestFocus) {
return;
}
_setAsFocusedChild(); _setAsFocusedChild();
if (hasPrimaryFocus) { if (hasPrimaryFocus) {
return; return;
...@@ -795,6 +848,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { ...@@ -795,6 +848,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<BuildContext>('context', context, defaultValue: null)); properties.add(DiagnosticsProperty<BuildContext>('context', context, defaultValue: null));
properties.add(FlagProperty('canRequestFocus', value: canRequestFocus, ifFalse: 'NOT FOCUSABLE', defaultValue: true));
properties.add(FlagProperty('hasFocus', value: hasFocus, ifTrue: 'FOCUSED', defaultValue: false)); properties.add(FlagProperty('hasFocus', value: hasFocus, ifTrue: 'FOCUSED', defaultValue: false));
properties.add(StringProperty('debugLabel', debugLabel, defaultValue: null)); properties.add(StringProperty('debugLabel', debugLabel, defaultValue: null));
} }
...@@ -861,8 +915,7 @@ class FocusScopeNode extends FocusNode { ...@@ -861,8 +915,7 @@ class FocusScopeNode extends FocusNode {
/// ///
/// Returns null if there is no currently focused child. /// Returns null if there is no currently focused child.
FocusNode get focusedChild { FocusNode get focusedChild {
assert(_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this, assert(_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this, 'Focused child does not have the same idea of its enclosing scope as the scope does.');
'Focused child does not have the same idea of its enclosing scope as the scope does.');
return _focusedChildren.isNotEmpty ? _focusedChildren.last : null; return _focusedChildren.isNotEmpty ? _focusedChildren.last : null;
} }
...@@ -904,14 +957,17 @@ class FocusScopeNode extends FocusNode { ...@@ -904,14 +957,17 @@ class FocusScopeNode extends FocusNode {
if (node._parent == null) { if (node._parent == null) {
_reparent(node); _reparent(node);
} }
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(); node._doRequestFocus();
} }
} }
@override @override
void _doRequestFocus() { void _doRequestFocus() {
if (!canRequestFocus) {
return;
}
// 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;
......
...@@ -146,10 +146,10 @@ class Focus extends StatefulWidget { ...@@ -146,10 +146,10 @@ class Focus extends StatefulWidget {
this.onFocusChange, this.onFocusChange,
this.onKey, this.onKey,
this.debugLabel, this.debugLabel,
this.skipTraversal = false, this.canRequestFocus,
this.skipTraversal,
}) : assert(child != null), }) : assert(child != null),
assert(autofocus != null), assert(autofocus != null),
assert(skipTraversal != null),
super(key: key); super(key: key);
/// A debug label for this widget. /// A debug label for this widget.
...@@ -224,8 +224,33 @@ class Focus extends StatefulWidget { ...@@ -224,8 +224,33 @@ class Focus extends StatefulWidget {
/// ///
/// This is sometimes useful if a Focus widget should receive key events as /// This is sometimes useful if a Focus widget should receive key events as
/// part of the focus chain, but shouldn't be accessible via focus traversal. /// part of the focus chain, but shouldn't be accessible via focus traversal.
///
/// This is different from [canRequestFocus] because it only implies that the
/// widget can't be reached via traversal, not that it can't be focused. It may
/// still be focused explicitly.
final bool skipTraversal; final bool skipTraversal;
/// If true, this widget may request the primary focus.
///
/// Defaults to true. Set to false if you want the [FocusNode] this widget
/// manages to do nothing when [requestFocus] is called on it. Does not affect
/// the children of this node, and [FocusNode.hasFocus] can still return true
/// if this node is the ancestor of the primary focus.
///
/// This is different than [skipTraversal] because [skipTraversal] still
/// allows the widget to be focused, just not traversed to.
///
/// Setting [canRequestFocus] to false implies that the widget will also be
/// skipped for traversal purposes.
///
/// See also:
///
/// - [DefaultFocusTraversal], a widget that sets the traversal policy for
/// its descendants.
/// - [FocusTraversalPolicy], a class that can be extended to describe a
/// traversal policy.
final bool canRequestFocus;
/// Returns the [focusNode] of the [Focus] that most tightly encloses the /// Returns the [focusNode] of the [Focus] that most tightly encloses the
/// given [BuildContext]. /// given [BuildContext].
/// ///
...@@ -314,7 +339,8 @@ class _FocusState extends State<Focus> { ...@@ -314,7 +339,8 @@ class _FocusState extends State<Focus> {
// _createNode is overridden in _FocusScopeState. // _createNode is overridden in _FocusScopeState.
_internalNode ??= _createNode(); _internalNode ??= _createNode();
} }
focusNode.skipTraversal = widget.skipTraversal; focusNode.skipTraversal = widget.skipTraversal ?? focusNode.skipTraversal;
focusNode.canRequestFocus = widget.canRequestFocus ?? focusNode.canRequestFocus;
_focusAttachment = focusNode.attach(context, onKey: widget.onKey); _focusAttachment = focusNode.attach(context, onKey: widget.onKey);
_hasFocus = focusNode.hasFocus; _hasFocus = focusNode.hasFocus;
...@@ -324,7 +350,13 @@ class _FocusState extends State<Focus> { ...@@ -324,7 +350,13 @@ class _FocusState extends State<Focus> {
focusNode.addListener(_handleFocusChanged); focusNode.addListener(_handleFocusChanged);
} }
FocusNode _createNode() => FocusNode(debugLabel: widget.debugLabel); FocusNode _createNode() {
return FocusNode(
debugLabel: widget.debugLabel,
canRequestFocus: widget.canRequestFocus ?? true,
skipTraversal: widget.skipTraversal ?? false,
);
}
@override @override
void dispose() { void dispose() {
...@@ -332,6 +364,7 @@ class _FocusState extends State<Focus> { ...@@ -332,6 +364,7 @@ class _FocusState extends State<Focus> {
// listening to it. // listening to it.
focusNode.removeListener(_handleFocusChanged); focusNode.removeListener(_handleFocusChanged);
_focusAttachment.detach(); _focusAttachment.detach();
// Don't manage the lifetime of external nodes given to the widget, just the // Don't manage the lifetime of external nodes given to the widget, just the
// internal node. // internal node.
_internalNode?.dispose(); _internalNode?.dispose();
...@@ -367,14 +400,14 @@ class _FocusState extends State<Focus> { ...@@ -367,14 +400,14 @@ class _FocusState extends State<Focus> {
}()); }());
if (oldWidget.focusNode == widget.focusNode) { if (oldWidget.focusNode == widget.focusNode) {
focusNode.skipTraversal = widget.skipTraversal ?? focusNode.skipTraversal;
focusNode.canRequestFocus = widget.canRequestFocus ?? focusNode.canRequestFocus;
return; return;
} }
_focusAttachment.detach(); _focusAttachment.detach();
focusNode.removeListener(_handleFocusChanged); focusNode.removeListener(_handleFocusChanged);
_initNode(); _initNode();
_hasFocus = focusNode.hasFocus;
} }
void _handleFocusChanged() { void _handleFocusChanged() {
......
...@@ -1151,6 +1151,96 @@ void main() { ...@@ -1151,6 +1151,96 @@ void main() {
expect(gotFocus, isTrue); expect(gotFocus, isTrue);
expect(node.hasFocus, isTrue); expect(node.hasFocus, isTrue);
}); });
testWidgets('Focus is ignored when set to not focusable.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1');
bool gotFocus;
await tester.pumpWidget(
Focus(
canRequestFocus: false,
onFocusChange: (bool focused) => gotFocus = focused,
child: Container(key: key1),
),
);
final Element firstNode = tester.element(find.byKey(key1));
final FocusNode node = Focus.of(firstNode);
node.requestFocus();
await tester.pump();
expect(gotFocus, isNull);
expect(node.hasFocus, isFalse);
});
testWidgets('Focus is lost when set to not focusable.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1');
bool gotFocus;
await tester.pumpWidget(
Focus(
autofocus: true,
canRequestFocus: true,
onFocusChange: (bool focused) => gotFocus = focused,
child: Container(key: key1),
),
);
Element firstNode = tester.element(find.byKey(key1));
FocusNode node = Focus.of(firstNode);
node.requestFocus();
await tester.pump();
expect(gotFocus, isTrue);
expect(node.hasFocus, isTrue);
gotFocus = null;
await tester.pumpWidget(
Focus(
canRequestFocus: false,
onFocusChange: (bool focused) => gotFocus = focused,
child: Container(key: key1),
),
);
firstNode = tester.element(find.byKey(key1));
node = Focus.of(firstNode);
node.requestFocus();
await tester.pump();
expect(gotFocus, false);
expect(node.hasFocus, isFalse);
});
testWidgets('Child of unfocusable Focus can get focus.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2');
final FocusNode focusNode = FocusNode();
bool gotFocus;
await tester.pumpWidget(
Focus(
canRequestFocus: false,
onFocusChange: (bool focused) => gotFocus = focused,
child: Focus(key: key1, focusNode: focusNode, child: Container(key: key2)),
),
);
final Element childWidget = tester.element(find.byKey(key1));
final FocusNode unfocusableNode = Focus.of(childWidget);
unfocusableNode.requestFocus();
await tester.pump();
expect(gotFocus, isNull);
expect(unfocusableNode.hasFocus, isFalse);
final Element containerWidget = tester.element(find.byKey(key2));
final FocusNode focusableNode = Focus.of(containerWidget);
focusableNode.requestFocus();
await tester.pump();
expect(gotFocus, isTrue);
expect(unfocusableNode.hasFocus, isTrue);
});
}); });
testWidgets('Nodes are removed when all Focuses are removed.', (WidgetTester tester) async { testWidgets('Nodes are removed when all Focuses are removed.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
......
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