diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart index 12202531039..54d89ff2316 100644 --- a/packages/flutter/lib/src/widgets/focus_manager.dart +++ b/packages/flutter/lib/src/widgets/focus_manager.dart @@ -517,21 +517,76 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { /// focus traversal policy for a widget subtree. /// * [FocusTraversalPolicy], a class that can be extended to describe a /// traversal policy. - bool get canRequestFocus => _canRequestFocus && ancestors.every(_allowDescendantsToBeFocused); + bool get canRequestFocus => _canRequestFocus && (_focusabilityListenable?.value ?? _computeAncestorsAllowFocus()); + bool _computeAncestorsAllowFocus() => ancestors.every(_allowDescendantsToBeFocused); static bool _allowDescendantsToBeFocused(FocusNode ancestor) => ancestor.descendantsAreFocusable; bool _canRequestFocus; @mustCallSuper set canRequestFocus(bool value) { - if (value != _canRequestFocus) { - // Have to set this first before unfocusing, since it checks this to cull - // unfocusable, previously-focused children. - _canRequestFocus = value; - if (hasFocus && !value) { - unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); - } - _manager?._markPropertiesChanged(this); + if (value == _canRequestFocus) { + return; } + // Have to set this first before unfocusing, since it checks this to cull + // unfocusable, previously-focused children. + _canRequestFocus = value; + if (hasFocus && !value) { + unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + } + + final _FocusabilityListenable? focusabilityListenable = _focusabilityListenable; + if (focusabilityListenable != null && focusabilityListenable.hasListeners) { + final bool ancestorsAllowFocus = focusabilityListenable._ancestorsAllowFocus = _adjustListeningNodeCountForAncestors(value ? 1 : -1); + if (ancestorsAllowFocus) { + focusabilityListenable.notifyListeners(); + } + } + _manager?._markPropertiesChanged(this); + } + + // The number of descendant focus nodes whose focusability must be + // re-evaluated, when this node's `descentantsAreFocusable` value changes. + // This does not include nodes with `_canRequestFocus` set to false, even when + // their focusability listenable has listeners. + int _focusabilityListeningDescendantCount = 0; + + /// A [ValueListenable] that notifies registered listeners when the + /// focusability of this [FocusNode] changes. + /// + /// The [ValueListenable]'s `value` indicates whether this [FocusNode] can + /// request primary focus. It's value is always consistent with the return + /// value of the [canRequestFocus] getter, which only returns true when the + /// [FocusNode]'s [canRequestFocus] setter is set to true, and all of its + /// ancestors in the focus tree have [FocusNode.descendantsAreFocusable] set to + /// true. + /// + /// Unlike listeners added to the [FocusNode] itself, which won't be notified + /// until focus changes are applied in microtasks, listeners added to + /// [focusabilityListenable] are notified immediately as the [FocusNode]'s + /// focusability changes. + /// + /// This can be used to monitor, for example, whether a text field is currently + /// disabled, or in an inactive route, thus isn't receiving user interactions, + /// so that text field can unsubscribe itself from system services such as + /// scribble and autofill when it becomes unfocusable, and re-subscribe when it + /// becomes focusable again. + /// + /// This [ValueListenable] is managed by this [FocusNode]. It must not be used + /// after the [FocusNode] itself is disposed. + ValueListenable get focusabilityListenable => _focusabilityListenable ??= _FocusabilityListenable(this); + _FocusabilityListenable? _focusabilityListenable; + + // Returns whether all ancestors have `descendantsAreFocusable` set to true. + bool _adjustListeningNodeCountForAncestors(int delta) { + assert(delta != 0); + for (FocusNode? node = parent; node != null; node = node.parent) { + node._focusabilityListeningDescendantCount += delta; + assert(node._focusabilityListeningDescendantCount >= 0); + if (!node.descendantsAreFocusable) { + return false; + } + } + return true; } /// If false, will disable focus for all of this node's descendants. @@ -574,9 +629,47 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { if (!value && hasFocus) { unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); } + _onDescendantsAreFocusableChanged(value); _manager?._markPropertiesChanged(this); } + void _onDescendantsAreFocusableChanged(bool newValue) { + assert(_focusabilityListeningDescendantCount >= 0); + final int ancestorListenerAdjustment = newValue ? _focusabilityListeningDescendantCount : -_focusabilityListeningDescendantCount; + + // If there's an ancestor that disallows focus, changing the + // `descendantsAreFocusable` value of this node never affects the + // focusability of the descendants. Notify _focusabilityListenerCount + // listeners only when this is not the case. + final bool notifyChildren = ancestorListenerAdjustment != 0 + && _adjustListeningNodeCountForAncestors(ancestorListenerAdjustment); + if (notifyChildren) { + assert(children.isNotEmpty); + for (final FocusNode child in children) { + child._notifyFocusabilityListenersInSubtree(newValue); + } + } + } + + void _notifyFocusabilityListenersInSubtree(bool ancestorsAllowFocus) { + final _FocusabilityListenable? focusabilityListenable = _focusabilityListenable; + if (_canRequestFocus && focusabilityListenable != null && focusabilityListenable.hasListeners) { + assert(ancestorsAllowFocus == _computeAncestorsAllowFocus()); + assert(ancestorsAllowFocus != focusabilityListenable._ancestorsAllowFocus); + focusabilityListenable._ancestorsAllowFocus = ancestorsAllowFocus; + focusabilityListenable.notifyListeners(); + } + + if (_focusabilityListeningDescendantCount > 0 && descendantsAreFocusable) { + // Further propagate to children whose focusability is determined by this + // node's ancestors. + assert(children.isNotEmpty); + for (final FocusNode child in children) { + child._notifyFocusabilityListenersInSubtree(ancestorsAllowFocus); + } + } + } + /// If false, tells the focus traversal policy to skip over for all of this /// node's descendants for purposes of the traversal algorithm. /// @@ -1011,7 +1104,8 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { @mustCallSuper void _reparent(FocusNode child) { assert(child != this, 'Tried to make a child into a parent of itself.'); - if (child._parent == this) { + final FocusNode? oldParent = child._parent; + if (oldParent == this) { assert(_children.contains(child), "Found a node that says it's a child, but doesn't appear in the child list."); // The child is already a child of this parent. return; @@ -1020,7 +1114,15 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { 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, removeScopeFocus: oldScope != nearestScope); + + final _FocusabilityListenable? childFocusabilityListenable = child._focusabilityListenable; + final int childSubtreeListenerCount = (child.descendantsAreFocusable ? child._focusabilityListeningDescendantCount : 0) + + (child._canRequestFocus && childFocusabilityListenable != null && childFocusabilityListenable.hasListeners ? 1 : 0); + // If childSubtreeListenerCount == 0, we don't care about focusability since there are no listeners. + final bool childCouldFocus = childSubtreeListenerCount > 0 + && child._adjustListeningNodeCountForAncestors(-childSubtreeListenerCount); + oldParent?._removeChild(child, removeScopeFocus: oldScope != nearestScope); + _children.add(child); child._parent = this; child._ancestors = null; @@ -1028,6 +1130,14 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { for (final FocusNode ancestor in child.ancestors) { ancestor._descendants = null; } + + if (childSubtreeListenerCount > 0) { + final bool childCanFocus = child._adjustListeningNodeCountForAncestors(childSubtreeListenerCount); + if (childCanFocus != childCouldFocus) { + child._notifyFocusabilityListenersInSubtree(childCanFocus); + } + } + if (hadFocus) { // Update the focus chain for the current focus without changing it. _manager?.primaryFocus?._setAsFocusedChildForScope(); @@ -1073,6 +1183,8 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier { @override void dispose() { + _focusabilityListenable?.dispose(); + _focusabilityListenable = null; // Detaching will also unfocus and clean up the manager's data structures. _attachment?.detach(); super.dispose(); @@ -1271,6 +1383,14 @@ class FocusScopeNode extends FocusNode { this.traversalEdgeBehavior = TraversalEdgeBehavior.closedLoop, }) : super(descendantsAreFocusable: true); + @override + set canRequestFocus(bool value) { + if (value != _canRequestFocus) { + super.canRequestFocus = value; + _onDescendantsAreFocusableChanged(value); + } + } + @override FocusScopeNode get nearestScope => this; @@ -1414,6 +1534,42 @@ class FocusScopeNode extends FocusNode { } } +class _FocusabilityListenable extends ChangeNotifier implements ValueListenable { + _FocusabilityListenable(this.node); + + final FocusNode node; + + @override + bool get value { + assert(!hasListeners || _ancestorsAllowFocus == node._computeAncestorsAllowFocus()); + return node._canRequestFocus && (hasListeners ? _ancestorsAllowFocus : node._computeAncestorsAllowFocus()); + } + + // True if all ancestors of `node` have `descentantsAreFocusable` set to + // true. The value is only maintained when there are listeners, and + // `node._canRequestFocus` is true. + bool _ancestorsAllowFocus = true; + + @override + void addListener(VoidCallback listener) { + final bool hadListener = hasListeners; + super.addListener(listener); + assert(hasListeners); + if (!hadListener && node._canRequestFocus) { + _ancestorsAllowFocus = node._adjustListeningNodeCountForAncestors(1); + } + } + + @override + void removeListener(VoidCallback listener) { + final bool hadListener = hasListeners; + super.removeListener(listener); + if (node._canRequestFocus && hadListener && !hasListeners) { + _ancestorsAllowFocus = node._adjustListeningNodeCountForAncestors(-1); + } + } +} + /// An enum to describe which kind of focus highlight behavior to use when /// displaying focus information. enum FocusHighlightMode { diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart index 8c87bcb0609..53204361233 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -2108,6 +2108,275 @@ void main() { tester.binding.focusManager.removeListener(handleFocusChange); }); + group('focusability listener', () { + int focusabilityChangeCount = 0; + void focusabilityCallback() { + focusabilityChangeCount += 1; + } + + setUp(() { focusabilityChangeCount = 0; }); + + testWidgets('canRequestFocus affects focusability of the node', (WidgetTester tester) async { + int node2CallbackCounter = 0; + void node2Callback() { node2CallbackCounter += 1; } + final FocusNode node1 = FocusNode(debugLabel: 'node 1')..focusabilityListenable.addListener(focusabilityCallback); + final FocusNode node2 = FocusNode(debugLabel: 'node 2')..focusabilityListenable.addListener(node2Callback); + + addTearDown(node1.dispose); + addTearDown(node2.dispose); + + await tester.pumpWidget( + Focus( + focusNode: node1, + child: Focus( + focusNode: node2, + child: const SizedBox(), + ), + ), + ); + + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 0); + expect(node2.focusabilityListenable.value, isTrue); + expect(node2CallbackCounter, 0); + + node1.canRequestFocus = false; + expect(node1.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 1); + expect(node2.focusabilityListenable.value, isTrue); + expect(node2CallbackCounter, 0); + + node1.canRequestFocus = true; + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 2); + expect(node2.focusabilityListenable.value, isTrue); + expect(node2CallbackCounter, 0); + + node2.canRequestFocus = false; + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 2); + expect(node2.focusabilityListenable.value, isFalse); + expect(node2CallbackCounter, 1); + + node2.canRequestFocus = true; + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 2); + expect(node2.focusabilityListenable.value, isTrue); + expect(node2CallbackCounter, 2); + }); + + testWidgets('descendantsAreFocusable affects focusability of the descendants', (WidgetTester tester) async { + int node2CallbackCounter = 0; + void node2Callback() { node2CallbackCounter += 1; } + final FocusNode node1 = FocusNode(debugLabel: 'node 1')..focusabilityListenable.addListener(focusabilityCallback); + final FocusNode node2 = FocusNode(debugLabel: 'node 2', descendantsAreFocusable: false)..focusabilityListenable.addListener(node2Callback); + + addTearDown(node1.dispose); + addTearDown(node2.dispose); + + await tester.pumpWidget( + Focus( + focusNode: node1, + child: Focus( + focusNode: node2, + child: const SizedBox(), + ), + ), + ); + + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 0); + expect(node2.focusabilityListenable.value, isTrue); + expect(node2CallbackCounter, 0); + + node1.descendantsAreFocusable = false; + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 0); + expect(node2.focusabilityListenable.value, isFalse); + expect(node2CallbackCounter, 1); + + node1.descendantsAreFocusable = true; + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 0); + expect(node2.focusabilityListenable.value, isTrue); + expect(node2CallbackCounter, 2); + + node2.descendantsAreFocusable = false; + expect(node1.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 0); + expect(node2.focusabilityListenable.value, isTrue); + expect(node2CallbackCounter, 2); + }); + + testWidgets('Reparenting affects focusability of the node', (WidgetTester tester) async { + int node3CallbackCounter = 0; + void node3Callback() { node3CallbackCounter += 1; } + final FocusNode node1 = FocusNode(debugLabel: 'node 1'); + final FocusNode node2 = FocusNode(debugLabel: 'node 2', descendantsAreFocusable: false); + final FocusNode node3 = FocusNode(debugLabel: 'node 3')..focusabilityListenable.addListener(node3Callback); + final FocusNode node4 = FocusNode(debugLabel: 'node 4')..focusabilityListenable.addListener(focusabilityCallback); + addTearDown(node1.dispose); + addTearDown(node2.dispose); + addTearDown(node3.dispose); + addTearDown(node4.dispose); + + await tester.pumpWidget( + Focus( + focusNode: node1, + child: Focus( + focusNode: node2, + child: Column( + children: [ + Focus(focusNode: node3, child: Container()), + Focus(focusNode: node4, child: Container()), + ], + ) + ), + ), + ); + + // The listeners are notified on reparent. + expect(node4.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 1); + expect(node3.focusabilityListenable.value, isFalse); + expect(node3CallbackCounter, 1); + + // Swap node 1 and node 3. + await tester.pumpWidget( + Focus( + focusNode: node3, + child: Focus( + focusNode: node2, + child: Column( + children: [ + Focus(focusNode: node1, child: Container()), + Focus(focusNode: node4, child: Container()), + ], + ) + ), + ), + ); + + expect(node4.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 1); + expect(node3.focusabilityListenable.value, isTrue); + expect(node3CallbackCounter, 2); + + // Swap node 1 and node 2. + await tester.pumpWidget( + Focus( + focusNode: node3, + child: Focus( + focusNode: node1, + child: Column( + children: [ + Focus(focusNode: node2, child: Container()), + Focus(focusNode: node4, child: Container()), + ], + ) + ), + ), + ); + + expect(node4.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 2); + expect(node3.focusabilityListenable.value, isTrue); + expect(node3CallbackCounter, 2); + + // Swap node 2 and node 4. + await tester.pumpWidget( + Focus( + focusNode: node3, + child: Focus( + focusNode: node1, + child: Column( + children: [ + Focus(focusNode: node4, child: Container()), + Focus(focusNode: node2, child: Container()), + ], + ) + ), + ), + ); + + expect(node4.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 2); + expect(node3.focusabilityListenable.value, isTrue); + expect(node3CallbackCounter, 2); + + // Return to the initial state + await tester.pumpWidget( + Focus( + focusNode: node1, + child: Focus( + focusNode: node2, + child: Column( + children: [ + Focus(focusNode: node3, child: Container()), + Focus(focusNode: node4, child: Container()), + ], + ) + ), + ), + ); + + expect(node4.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 3); + expect(node3.focusabilityListenable.value, isFalse); + expect(node3CallbackCounter, 3); + }); + + testWidgets('does not get called in dispose', (WidgetTester tester) async { + final FocusNode node1 = FocusNode(debugLabel: 'node 1')..focusabilityListenable.addListener(focusabilityCallback); + final FocusNode node2 = FocusNode(debugLabel: 'node 2')..focusabilityListenable.addListener(focusabilityCallback); + + await tester.pumpWidget( + Focus( + descendantsAreFocusable: false, + child: Column( + children: [ + Focus(focusNode: node1, child: Container()), + Focus(focusNode: node2, child: Container()), + ], + ), + ), + ); + expect(focusabilityChangeCount, 2); + + await tester.pumpWidget(const SizedBox()); + expect(focusabilityChangeCount, 2); + }); + + testWidgets('Adding removing listeners many times', (WidgetTester tester) async { + final FocusNode node1 = FocusNode(debugLabel: 'node 1')..focusabilityListenable.addListener(focusabilityCallback); + final FocusNode node2 = FocusNode(debugLabel: 'node 2'); + + for (int i = 0; i < 100; i += 1) { + node1.focusabilityListenable.removeListener(focusabilityCallback); + node1.focusabilityListenable.removeListener(focusabilityCallback); + node1.focusabilityListenable.addListener(focusabilityCallback); + node1.focusabilityListenable.removeListener(focusabilityCallback); + } + node1.focusabilityListenable.addListener(focusabilityCallback); + node1.focusabilityListenable.addListener(focusabilityCallback); + node2.focusabilityListenable.addListener(focusabilityCallback); + expect(focusabilityChangeCount, 0); + + await tester.pumpWidget( + Focus( + descendantsAreFocusable: false, + child: Column( + children: [ + Focus(focusNode: node1, child: Container()), + Focus(focusNode: node2, child: Container()), + ], + ), + ), + ); + expect(focusabilityChangeCount, 3); + }); + }); + testWidgets('debugFocusChanges causes logging of focus changes', (WidgetTester tester) async { final bool oldDebugFocusChanges = debugFocusChanges; final DebugPrintCallback oldDebugPrint = debugPrint; diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart index 9203c6a9065..ad2a3c91e99 100644 --- a/packages/flutter/test/widgets/focus_scope_test.dart +++ b/packages/flutter/test/widgets/focus_scope_test.dart @@ -2141,6 +2141,169 @@ void main() { expect(childFocusNode.canRequestFocus, isTrue); }); }); + + group('focusability listener', () { + int focusabilityChangeCount = 0; + void focusabilityCallback() { + focusabilityChangeCount += 1; + } + + setUp(() { focusabilityChangeCount = 0; }); + + testWidgets('canRequestFocus affects child focusability', (WidgetTester tester) async { + final FocusScopeNode scopeNode1 = FocusScopeNode(debugLabel: 'scope1'); + final FocusScopeNode scopeNode2 = FocusScopeNode(debugLabel: 'scope2'); + final FocusNode node1 = FocusNode(debugLabel: 'node 1'); + final FocusNode node2 = FocusNode(debugLabel: 'node 2'); + final FocusNode node3 = FocusNode(debugLabel: 'node 3'); + addTearDown(scopeNode1.dispose); + addTearDown(scopeNode2.dispose); + addTearDown(node1.dispose); + addTearDown(node2.dispose); + addTearDown(node3.dispose); + + await tester.pumpWidget( + FocusScope( + node: scopeNode1, + child: Column( + children: [ + Focus( + focusNode: node1, + child: Container(), + ), + Focus( + focusNode: node2, + child: FocusScope( + node: scopeNode2, + child: Focus(focusNode: node3, child: const SizedBox()), + ), + ), + ], + ), + ), + ); + + node3.focusabilityListenable.addListener(focusabilityCallback); + int node1FocusabilityCallbackCount = 0; + node1.focusabilityListenable.addListener(() => node1FocusabilityCallbackCount += 1); + + scopeNode1.canRequestFocus = false; + expect(node3.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 1); + expect(node1.focusabilityListenable.value, isFalse); + expect(node1FocusabilityCallbackCount, 1); + + scopeNode2.canRequestFocus = false; + expect(node3.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 1); + expect(node1.focusabilityListenable.value, isFalse); + expect(node1FocusabilityCallbackCount, 1); + + scopeNode1.canRequestFocus = true; + expect(node3.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 1); + expect(node1.focusabilityListenable.value, isTrue); + expect(node1FocusabilityCallbackCount, 2); + + scopeNode2.canRequestFocus = true; + expect(node3.focusabilityListenable.value, isTrue); + expect(focusabilityChangeCount, 2); + expect(node1.focusabilityListenable.value, isTrue); + expect(node1FocusabilityCallbackCount, 2); + }); + + testWidgets('onFocusabilityCallback invoked on mount, if not focusable', (WidgetTester tester) async { + final FocusScopeNode scopeNode1 = FocusScopeNode(debugLabel: 'scope1', canRequestFocus: false); + final FocusNode node1 = FocusNode(debugLabel: 'node 1')..focusabilityListenable.addListener(focusabilityCallback); + addTearDown(scopeNode1.dispose); + addTearDown(node1.dispose); + + await tester.pumpWidget( + FocusScope( + node: scopeNode1, + child: Column( + children: [ + Focus( + focusNode: node1, + child: Container(), + ), + ], + ), + ), + ); + + expect(node1.focusabilityListenable.value, isFalse); + expect(focusabilityChangeCount, 1); + }); + + testWidgets('onFocusabilityCallback is not invoked on mount, if focusable', (WidgetTester tester) async { + final FocusScopeNode scopeNode1 = FocusScopeNode(debugLabel: 'scope1'); + final FocusNode node1 = FocusNode(debugLabel: 'node 1')..focusabilityListenable.addListener(focusabilityCallback); + addTearDown(scopeNode1.dispose); + addTearDown(node1.dispose); + + await tester.pumpWidget( + FocusScope( + node: scopeNode1, + child: Column( + children: [ + Focus( + focusNode: node1, + child: Container(), + ), + ], + ), + ), + ); + + expect(focusabilityChangeCount, 0); + }); + + testWidgets('onFocusabilityCallback on scope node', (WidgetTester tester) async { + final FocusScopeNode scopeNode1 = FocusScopeNode(debugLabel: 'scope1'); + final FocusScopeNode scopeNode2 = FocusScopeNode(debugLabel: 'scope2')..focusabilityListenable.addListener(focusabilityCallback); + addTearDown(scopeNode1.dispose); + addTearDown(scopeNode2.dispose); + + await tester.pumpWidget( + FocusScope( + node: scopeNode1, + child: FocusScope(node: scopeNode2, child: Container()) + ), + ); + + expect(focusabilityChangeCount, 0); + + scopeNode2.canRequestFocus = false; + expect(focusabilityChangeCount, 1); + expect(scopeNode2.focusabilityListenable.value, isFalse); + + scopeNode2.canRequestFocus = true; + expect(focusabilityChangeCount, 2); + expect(scopeNode2.focusabilityListenable.value, isTrue); + + // scope 2 has no descendants. + scopeNode2.descendantsAreFocusable = false; + expect(focusabilityChangeCount, 2); + expect(scopeNode2.focusabilityListenable.value, isTrue); + + scopeNode1.descendantsAreFocusable = false; + expect(focusabilityChangeCount, 3); + expect(scopeNode2.focusabilityListenable.value, isFalse); + + scopeNode1.descendantsAreFocusable = true; + expect(focusabilityChangeCount, 4); + expect(scopeNode2.focusabilityListenable.value, isTrue); + + scopeNode1.canRequestFocus = false; + expect(focusabilityChangeCount, 5); + expect(scopeNode2.focusabilityListenable.value, isFalse); + + scopeNode1.canRequestFocus = true; + expect(focusabilityChangeCount, 6); + expect(scopeNode2.focusabilityListenable.value, isTrue); + }); + }); } class TestFocus extends StatefulWidget {