diff --git a/dev/manual_tests/lib/focus.dart b/dev/manual_tests/lib/focus.dart index 4f21eb79ec..ca7faa99b7 100644 --- a/dev/manual_tests/lib/focus.dart +++ b/dev/manual_tests/lib/focus.dart @@ -13,22 +13,56 @@ void main() { )); } -class DemoButton extends StatelessWidget { - const DemoButton({this.name}); +class DemoButton extends StatefulWidget { + const DemoButton({this.name, this.canRequestFocus = true, this.autofocus = false}); final String name; + final bool canRequestFocus; + final bool autofocus; + + @override + _DemoButtonState createState() => _DemoButtonState(); +} + +class _DemoButtonState extends State { + 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() { - print('Button $name pressed.'); + focusNode.requestFocus(); + print('Button ${widget.name} pressed.'); + debugDumpFocusTree(); } @override Widget build(BuildContext context) { return FlatButton( + focusNode: focusNode, + autofocus: widget.autofocus, focusColor: Colors.red, hoverColor: Colors.blue, onPressed: () => _handleOnPressed(), - child: Text(name), + child: Text(widget.name), ); } } @@ -119,14 +153,20 @@ class _FocusDemoState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ - DemoButton(name: 'One'), + DemoButton( + name: 'One', + autofocus: true, + ), ], ), Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ DemoButton(name: 'Two'), - DemoButton(name: 'Three'), + DemoButton( + name: 'Three', + canRequestFocus: false, + ), ], ), Row( diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index dda598565b..4a80870b65 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -367,8 +367,12 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { FocusNode({ String debugLabel, FocusOnKeyCallback onKey, - this.skipTraversal = false, + bool skipTraversal = false, + bool canRequestFocus = true, }) : assert(skipTraversal != null), + assert(canRequestFocus != null), + _skipTraversal = skipTraversal, + _canRequestFocus = canRequestFocus, _onKey = onKey { // Set it via the setter so that it does nothing on release builds. this.debugLabel = debugLabel; @@ -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 /// not traversed, allowing them to receive key events as part of the focus /// 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]. /// @@ -413,7 +460,11 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { /// An iterator over the children that are allowed to be traversed by the /// [FocusTraversalPolicy]. - Iterable get traversalChildren => children.where((FocusNode node) => !node.skipTraversal); + Iterable get traversalChildren { + return children.where( + (FocusNode node) => !node.skipTraversal && node.canRequestFocus, + ); + } /// A debug label that is used for diagnostic output. /// @@ -440,7 +491,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { } /// Returns all descendants which do not have the [skipTraversal] flag set. - Iterable get traversalDescendants => descendants.where((FocusNode node) => !node.skipTraversal); + Iterable get traversalDescendants => descendants.where((FocusNode node) => !node.skipTraversal && node.canRequestFocus); /// An [Iterable] over the ancestors of this node. /// @@ -733,8 +784,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { if (node._parent == null) { _reparent(node); } - assert(node.ancestors.contains(this), - 'Focus was requested for a node that is not a descendant of the scope from which it was requested.'); + 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(); return; } @@ -743,6 +793,9 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { // Note that this is overridden in FocusScopeNode. void _doRequestFocus() { + if (!canRequestFocus) { + return; + } _setAsFocusedChild(); if (hasPrimaryFocus) { return; @@ -795,6 +848,7 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('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(StringProperty('debugLabel', debugLabel, defaultValue: null)); } @@ -861,8 +915,7 @@ class FocusScopeNode extends FocusNode { /// /// Returns null if there is no currently focused child. FocusNode get focusedChild { - assert(_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this, - 'Focused child does not have the same idea of its enclosing scope as the scope does.'); + assert(_focusedChildren.isEmpty || _focusedChildren.last.enclosingScope == this, 'Focused child does not have the same idea of its enclosing scope as the scope does.'); return _focusedChildren.isNotEmpty ? _focusedChildren.last : null; } @@ -904,14 +957,17 @@ class FocusScopeNode extends FocusNode { if (node._parent == null) { _reparent(node); } - assert(node.ancestors.contains(this), - 'Autofocus was requested for a node that is not a descendant of the scope from which it was requested.'); + 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(); } } @override void _doRequestFocus() { + if (!canRequestFocus) { + return; + } + // 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; diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart index bdf09a1d74..3ef2e55656 100644 --- a/packages/flutter/lib/src/widgets/focus_scope.dart +++ b/packages/flutter/lib/src/widgets/focus_scope.dart @@ -146,10 +146,10 @@ class Focus extends StatefulWidget { this.onFocusChange, this.onKey, this.debugLabel, - this.skipTraversal = false, + this.canRequestFocus, + this.skipTraversal, }) : assert(child != null), assert(autofocus != null), - assert(skipTraversal != null), super(key: key); /// A debug label for this widget. @@ -224,8 +224,33 @@ class Focus extends StatefulWidget { /// /// 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. + /// + /// 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; + /// 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 /// given [BuildContext]. /// @@ -314,7 +339,8 @@ class _FocusState extends State { // _createNode is overridden in _FocusScopeState. _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); _hasFocus = focusNode.hasFocus; @@ -324,7 +350,13 @@ class _FocusState extends State { 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 void dispose() { @@ -332,6 +364,7 @@ class _FocusState extends State { // listening to it. focusNode.removeListener(_handleFocusChanged); _focusAttachment.detach(); + // Don't manage the lifetime of external nodes given to the widget, just the // internal node. _internalNode?.dispose(); @@ -367,14 +400,14 @@ class _FocusState extends State { }()); if (oldWidget.focusNode == widget.focusNode) { + focusNode.skipTraversal = widget.skipTraversal ?? focusNode.skipTraversal; + focusNode.canRequestFocus = widget.canRequestFocus ?? focusNode.canRequestFocus; return; } _focusAttachment.detach(); focusNode.removeListener(_handleFocusChanged); _initNode(); - - _hasFocus = focusNode.hasFocus; } void _handleFocusChanged() { diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart index 82f4b06936..497c3d19b0 100644 --- a/packages/flutter/test/widgets/focus_scope_test.dart +++ b/packages/flutter/test/widgets/focus_scope_test.dart @@ -1151,6 +1151,96 @@ void main() { expect(gotFocus, 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 { final GlobalKey key1 = GlobalKey(debugLabel: '1');