Adds ability to mark a subtree as not traversable (#94626)

diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart
index a600271..d52dbfd 100644
--- a/packages/flutter/lib/src/widgets/actions.dart
+++ b/packages/flutter/lib/src/widgets/actions.dart
@@ -1064,6 +1064,7 @@
     this.focusNode,
     this.autofocus = false,
     this.descendantsAreFocusable = true,
+    this.descendantsAreTraversable = true,
     this.shortcuts,
     this.actions,
     this.onShowFocusHighlight,
@@ -1095,6 +1096,9 @@
   /// {@macro flutter.widgets.Focus.descendantsAreFocusable}
   final bool descendantsAreFocusable;
 
+  /// {@macro flutter.widgets.Focus.descendantsAreTraversable}
+  final bool descendantsAreTraversable;
+
   /// {@macro flutter.widgets.actions.actions}
   final Map<Type, Action<Intent>>? actions;
 
@@ -1281,6 +1285,7 @@
         focusNode: widget.focusNode,
         autofocus: widget.autofocus,
         descendantsAreFocusable: widget.descendantsAreFocusable,
+        descendantsAreTraversable: widget.descendantsAreTraversable,
         canRequestFocus: _canRequestFocus,
         onFocusChange: _handleFocusChange,
         child: widget.child,
diff --git a/packages/flutter/lib/src/widgets/focus_manager.dart b/packages/flutter/lib/src/widgets/focus_manager.dart
index a9dc624..cd1c2e3 100644
--- a/packages/flutter/lib/src/widgets/focus_manager.dart
+++ b/packages/flutter/lib/src/widgets/focus_manager.dart
@@ -409,12 +409,14 @@
     bool skipTraversal = false,
     bool canRequestFocus = true,
     bool descendantsAreFocusable = true,
+    bool descendantsAreTraversable = true,
   })  : assert(skipTraversal != null),
         assert(canRequestFocus != null),
         assert(descendantsAreFocusable != null),
         _skipTraversal = skipTraversal,
         _canRequestFocus = canRequestFocus,
-        _descendantsAreFocusable = descendantsAreFocusable {
+        _descendantsAreFocusable = descendantsAreFocusable,
+        _descendantsAreTraversable = descendantsAreTraversable {
     // Set it via the setter so that it does nothing on release builds.
     this.debugLabel = debugLabel;
   }
@@ -429,7 +431,17 @@
   /// 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 get skipTraversal {
+    if (_skipTraversal) {
+      return true;
+    }
+    for (final FocusNode ancestor in ancestors) {
+      if (!ancestor.descendantsAreTraversable) {
+        return true;
+      }
+    }
+    return false;
+  }
   bool _skipTraversal;
   set skipTraversal(bool value) {
     if (value != _skipTraversal) {
@@ -511,13 +523,17 @@
   ///
   /// See also:
   ///
-  ///  * [ExcludeFocus], a widget that uses this property to conditionally
-  ///    exclude focus for a subtree.
-  ///  * [Focus], a widget that exposes this setting as a parameter.
-  ///  * [FocusTraversalGroup], a widget used to group together and configure
-  ///    the focus traversal policy for a widget subtree that also has an
-  ///    `descendantsAreFocusable` parameter that prevents its children from
-  ///    being focused.
+  /// * [ExcludeFocus], a widget that uses this property to conditionally
+  ///   exclude focus for a subtree.
+  /// * [descendantsAreTraversable], which makes this widget's descendants
+  ///   untraversable.
+  /// * [ExcludeFocusTraversal], a widget that conditionally excludes focus
+  ///   traversal for a subtree.
+  /// * [Focus], a widget that exposes this setting as a parameter.
+  /// * [FocusTraversalGroup], a widget used to group together and configure
+  ///   the focus traversal policy for a widget subtree that also has an
+  ///   `descendantsAreFocusable` parameter that prevents its children from
+  ///   being focused.
   bool get descendantsAreFocusable => _descendantsAreFocusable;
   bool _descendantsAreFocusable;
   @mustCallSuper
@@ -534,6 +550,36 @@
     _manager?._markPropertiesChanged(this);
   }
 
+  /// If false, tells the focus traversal policy to skip over for all of this
+  /// node's descendants for purposes of the traversal algorithm.
+  ///
+  /// Defaults to true. Does not affect the focus traversal of this node: for
+  /// that, use [skipTraversal].
+  ///
+  /// Does not affect the value of [FocusNode.skipTraversal] on the
+  /// descendants. Does not affect focusability of the descendants.
+  ///
+  /// See also:
+  ///
+  /// * [ExcludeFocusTraversal], a widget that uses this property to conditionally
+  ///   exclude focus traversal for a subtree.
+  /// * [descendantsAreFocusable], which makes this widget's descendants
+  ///   unfocusable.
+  /// * [ExcludeFocus], a widget that conditionally excludes focus for a subtree.
+  /// * [FocusTraversalGroup], a widget used to group together and configure
+  ///   the focus traversal policy for a widget subtree that also has an
+  ///   `descendantsAreFocusable` parameter that prevents its children from
+  ///   being focused.
+  bool get descendantsAreTraversable => _descendantsAreTraversable;
+  bool _descendantsAreTraversable;
+  @mustCallSuper
+  set descendantsAreTraversable(bool value) {
+    if (value != _descendantsAreTraversable) {
+      _descendantsAreTraversable = value;
+      _manager?._markPropertiesChanged(this);
+    }
+  }
+
   /// The context that was supplied to [attach].
   ///
   /// This is typically the context for the widget that is being focused, as it
@@ -1105,6 +1151,7 @@
     super.debugFillProperties(properties);
     properties.add(DiagnosticsProperty<BuildContext>('context', context, defaultValue: null));
     properties.add(FlagProperty('descendantsAreFocusable', value: descendantsAreFocusable, ifFalse: 'DESCENDANTS UNFOCUSABLE', defaultValue: true));
+    properties.add(FlagProperty('descendantsAreTraversable', value: descendantsAreTraversable, ifFalse: 'DESCENDANTS UNTRAVERSABLE', defaultValue: true));
     properties.add(FlagProperty('canRequestFocus', value: canRequestFocus, ifFalse: 'NOT FOCUSABLE', defaultValue: true));
     properties.add(FlagProperty('hasFocus', value: hasFocus && !hasPrimaryFocus, ifTrue: 'IN FOCUS PATH', defaultValue: false));
     properties.add(FlagProperty('hasPrimaryFocus', value: hasPrimaryFocus, ifTrue: 'PRIMARY FOCUS', defaultValue: false));
diff --git a/packages/flutter/lib/src/widgets/focus_scope.dart b/packages/flutter/lib/src/widgets/focus_scope.dart
index 3cb56be..fa78734 100644
--- a/packages/flutter/lib/src/widgets/focus_scope.dart
+++ b/packages/flutter/lib/src/widgets/focus_scope.dart
@@ -124,6 +124,7 @@
     bool? canRequestFocus,
     bool? skipTraversal,
     bool? descendantsAreFocusable,
+    bool? descendantsAreTraversable,
     this.includeSemantics = true,
     String? debugLabel,
   })  : _onKeyEvent = onKeyEvent,
@@ -131,6 +132,7 @@
         _canRequestFocus = canRequestFocus,
         _skipTraversal = skipTraversal,
         _descendantsAreFocusable = descendantsAreFocusable,
+        _descendantsAreTraversable = descendantsAreTraversable,
         _debugLabel = debugLabel,
         assert(child != null),
         assert(autofocus != null),
@@ -279,10 +281,17 @@
   /// Does not affect the value of [FocusNode.canRequestFocus] on the
   /// descendants.
   ///
+  /// If a descendant node loses focus when this value is changed, the focus
+  /// will move to the scope enclosing this node.
+  ///
   /// See also:
   ///
   /// * [ExcludeFocus], a widget that uses this property to conditionally
   ///   exclude focus for a subtree.
+  /// * [descendantsAreTraversable], which makes this widget's descendants
+  ///   untraversable.
+  /// * [ExcludeFocusTraversal], a widget that conditionally excludes focus
+  ///   traversal for a subtree.
   /// * [FocusTraversalGroup], a widget used to group together and configure the
   ///   focus traversal policy for a widget subtree that has a
   ///   `descendantsAreFocusable` parameter to conditionally block focus for a
@@ -291,6 +300,30 @@
   bool get descendantsAreFocusable => _descendantsAreFocusable ?? focusNode?.descendantsAreFocusable ?? true;
   final bool? _descendantsAreFocusable;
 
+  /// {@template flutter.widgets.Focus.descendantsAreTraversable}
+  /// If false, will make this widget's descendants untraversable.
+  ///
+  /// Defaults to true. Does not affect traversablility of this node (just its
+  /// descendants): for that, use [FocusNode.skipTraversal].
+  ///
+  /// Does not affect the value of [FocusNode.skipTraversal] on the
+  /// descendants. Does not affect focusability of the descendants.
+  ///
+  /// See also:
+  ///
+  /// * [ExcludeFocusTraversal], a widget that uses this property to
+  ///   conditionally exclude focus traversal for a subtree.
+  /// * [descendantsAreFocusable], which makes this widget's descendants
+  ///   unfocusable.
+  /// * [ExcludeFocus], a widget that conditionally excludes focus for a subtree.
+  /// * [FocusTraversalGroup], a widget used to group together and configure the
+  ///   focus traversal policy for a widget subtree that has a
+  ///   `descendantsAreFocusable` parameter to conditionally block focus for a
+  ///   subtree.
+  /// {@endtemplate}
+  bool get descendantsAreTraversable => _descendantsAreTraversable ?? focusNode?.descendantsAreTraversable ?? true;
+  final bool? _descendantsAreTraversable;
+
   /// {@template flutter.widgets.Focus.includeSemantics}
   /// Include semantics information in this widget.
   ///
@@ -420,6 +453,7 @@
     properties.add(FlagProperty('autofocus', value: autofocus, ifTrue: 'AUTOFOCUS', defaultValue: false));
     properties.add(FlagProperty('canRequestFocus', value: canRequestFocus, ifFalse: 'NOT FOCUSABLE', defaultValue: false));
     properties.add(FlagProperty('descendantsAreFocusable', value: descendantsAreFocusable, ifFalse: 'DESCENDANTS UNFOCUSABLE', defaultValue: true));
+    properties.add(FlagProperty('descendantsAreTraversable', value: descendantsAreTraversable, ifFalse: 'DESCENDANTS UNTRAVERSABLE', defaultValue: true));
     properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
   }
 
@@ -459,6 +493,8 @@
   @override
   bool get descendantsAreFocusable => focusNode!.descendantsAreFocusable;
   @override
+  bool? get _descendantsAreTraversable => focusNode!.descendantsAreTraversable;
+  @override
   String? get debugLabel => focusNode!.debugLabel;
 }
 
@@ -468,6 +504,7 @@
   late bool _hadPrimaryFocus;
   late bool _couldRequestFocus;
   late bool _descendantsWereFocusable;
+  late bool _descendantsWereTraversable;
   bool _didAutofocus = false;
   FocusAttachment? _focusAttachment;
 
@@ -485,6 +522,7 @@
       _internalNode ??= _createNode();
     }
     focusNode.descendantsAreFocusable = widget.descendantsAreFocusable;
+    focusNode.descendantsAreTraversable = widget.descendantsAreTraversable;
     if (widget.skipTraversal != null) {
       focusNode.skipTraversal = widget.skipTraversal;
     }
@@ -493,6 +531,7 @@
     }
     _couldRequestFocus = focusNode.canRequestFocus;
     _descendantsWereFocusable = focusNode.descendantsAreFocusable;
+    _descendantsWereTraversable = focusNode.descendantsAreTraversable;
     _hadPrimaryFocus = focusNode.hasPrimaryFocus;
     _focusAttachment = focusNode.attach(context, onKeyEvent: widget.onKeyEvent, onKey: widget.onKey);
 
@@ -507,6 +546,7 @@
       debugLabel: widget.debugLabel,
       canRequestFocus: widget.canRequestFocus,
       descendantsAreFocusable: widget.descendantsAreFocusable,
+      descendantsAreTraversable: widget.descendantsAreTraversable,
       skipTraversal: widget.skipTraversal,
     );
   }
@@ -579,6 +619,7 @@
           focusNode.canRequestFocus = widget._canRequestFocus!;
         }
         focusNode.descendantsAreFocusable = widget.descendantsAreFocusable;
+        focusNode.descendantsAreTraversable = widget.descendantsAreTraversable;
       }
     } else {
       _focusAttachment!.detach();
@@ -595,6 +636,7 @@
     final bool hasPrimaryFocus = focusNode.hasPrimaryFocus;
     final bool canRequestFocus = focusNode.canRequestFocus;
     final bool descendantsAreFocusable = focusNode.descendantsAreFocusable;
+    final bool descendantsAreTraversable = focusNode.descendantsAreTraversable;
     widget.onFocusChange?.call(focusNode.hasFocus);
     // Check the cached states that matter here, and call setState if they have
     // changed.
@@ -613,6 +655,11 @@
         _descendantsWereFocusable = descendantsAreFocusable;
       });
     }
+    if (_descendantsWereTraversable != descendantsAreTraversable) {
+      setState(() {
+        _descendantsWereTraversable = descendantsAreTraversable;
+      });
+    }
   }
 
   @override
@@ -784,6 +831,8 @@
   @override
   bool get descendantsAreFocusable => focusNode!.descendantsAreFocusable;
   @override
+  bool get descendantsAreTraversable => focusNode!.descendantsAreTraversable;
+  @override
   String? get debugLabel => focusNode!.debugLabel;
 }
 
diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart
index 9cddb98..7a2d490 100644
--- a/packages/flutter/lib/src/widgets/focus_traversal.dart
+++ b/packages/flutter/lib/src/widgets/focus_traversal.dart
@@ -388,6 +388,11 @@
       }
     }
     final List<FocusNode> sortedNodes = _sortAllDescendants(nearestScope, currentNode);
+    if (sortedNodes.isEmpty) {
+      // If there are no nodes to traverse to, like when descendantsAreTraversable
+      // is false or skipTraversal for all the nodes is true.
+      return false;
+    }
     if (forward && focusedChild == sortedNodes.last) {
       _focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
       return true;
@@ -1446,10 +1451,12 @@
     Key? key,
     FocusTraversalPolicy? policy,
     this.descendantsAreFocusable = true,
+    this.descendantsAreTraversable = true,
     required this.child,
-  })  : assert(descendantsAreFocusable != null),
-        policy = policy ?? ReadingOrderTraversalPolicy(),
-        super(key: key);
+  }) : assert(descendantsAreFocusable != null),
+       assert(descendantsAreTraversable != null),
+       policy = policy ?? ReadingOrderTraversalPolicy(),
+       super(key: key);
 
   /// The policy used to move the focus from one focus node to another when
   /// traversing them using a keyboard.
@@ -1471,6 +1478,9 @@
   /// {@macro flutter.widgets.Focus.descendantsAreFocusable}
   final bool descendantsAreFocusable;
 
+  /// {@macro flutter.widgets.Focus.descendantsAreTraversable}
+  final bool descendantsAreTraversable;
+
   /// The child widget of this [FocusTraversalGroup].
   ///
   /// {@macro flutter.widgets.ProxyWidget.child}
@@ -1573,6 +1583,7 @@
         skipTraversal: true,
         includeSemantics: false,
         descendantsAreFocusable: widget.descendantsAreFocusable,
+        descendantsAreTraversable: widget.descendantsAreTraversable,
         child: widget.child,
       ),
     );
@@ -1737,3 +1748,62 @@
     }
   }
 }
+
+/// A widget that controls whether or not the descendants of this widget are
+/// traversable.
+///
+/// Does not affect the value of [FocusNode.skipTraversal] of the descendants.
+///
+/// See also:
+///
+///  * [Focus], a widget for adding and managing a [FocusNode] in the widget tree.
+///  * [ExcludeFocus], a widget that excludes its descendants from focusability.
+///  * [FocusTraversalGroup], a widget that groups widgets for focus traversal,
+///    and can also be used in the same way as this widget by setting its
+///    `descendantsAreFocusable` attribute.
+class ExcludeFocusTraversal extends StatelessWidget {
+  /// Const constructor for [ExcludeFocusTraversal] widget.
+  ///
+  /// The [excluding] argument must not be null.
+  ///
+  /// The [child] argument is required, and must not be null.
+  const ExcludeFocusTraversal({
+    Key? key,
+    this.excluding = true,
+    required this.child,
+  }) : assert(excluding != null),
+       assert(child != null),
+       super(key: key);
+
+  /// If true, will make this widget's descendants untraversable.
+  ///
+  /// Defaults to true.
+  ///
+  /// Does not affect the value of [FocusNode.skipTraversal] on the descendants.
+  ///
+  /// See also:
+  ///
+  /// * [Focus.descendantsAreTraversable], the attribute of a [Focus] widget that
+  ///   controls this same property for focus widgets.
+  /// * [FocusTraversalGroup], a widget used to group together and configure the
+  ///   focus traversal policy for a widget subtree that has a
+  ///   `descendantsAreFocusable` parameter to conditionally block focus for a
+  ///   subtree.
+  final bool excluding;
+
+  /// The child widget of this [ExcludeFocusTraversal].
+  ///
+  /// {@macro flutter.widgets.ProxyWidget.child}
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    return Focus(
+      canRequestFocus: false,
+      skipTraversal: true,
+      includeSemantics: false,
+      descendantsAreTraversable: !excluding,
+      child: child,
+    );
+  }
+}
diff --git a/packages/flutter/test/widgets/actions_test.dart b/packages/flutter/test/widgets/actions_test.dart
index c476ac5..0a7879d 100644
--- a/packages/flutter/test/widgets/actions_test.dart
+++ b/packages/flutter/test/widgets/actions_test.dart
@@ -962,6 +962,75 @@
         expect(buttonNode.hasFocus, isFalse);
       },
     );
+
+    testWidgets(
+      'FocusableActionDetector can prevent its descendants from being traversable',
+          (WidgetTester tester) async {
+        final FocusNode buttonNode1 = FocusNode(debugLabel: 'Button Node 1');
+        final FocusNode buttonNode2 = FocusNode(debugLabel: 'Button Node 2');
+
+        await tester.pumpWidget(
+          MaterialApp(
+            home: FocusableActionDetector(
+              child: Column(
+                children: <Widget>[
+                  MaterialButton(
+                    focusNode: buttonNode1,
+                    child: const Text('Node 1'),
+                    onPressed: () {},
+                  ),
+                  MaterialButton(
+                    focusNode: buttonNode2,
+                    child: const Text('Node 2'),
+                    onPressed: () {},
+                  ),
+                ],
+              ),
+            ),
+          ),
+        );
+
+        buttonNode1.requestFocus();
+        await tester.pump();
+        expect(buttonNode1.hasFocus, isTrue);
+        expect(buttonNode2.hasFocus, isFalse);
+        primaryFocus!.nextFocus();
+        await tester.pump();
+        expect(buttonNode1.hasFocus, isFalse);
+        expect(buttonNode2.hasFocus, isTrue);
+
+        await tester.pumpWidget(
+          MaterialApp(
+            home: FocusableActionDetector(
+              descendantsAreTraversable: false,
+              child: Column(
+                children: <Widget>[
+                  MaterialButton(
+                    focusNode: buttonNode1,
+                    child: const Text('Node 1'),
+                    onPressed: () {},
+                  ),
+                  MaterialButton(
+                    focusNode: buttonNode2,
+                    child: const Text('Node 2'),
+                    onPressed: () {},
+                  ),
+                ],
+              ),
+            ),
+          ),
+        );
+
+        buttonNode1.requestFocus();
+        await tester.pump();
+        expect(buttonNode1.hasFocus, isTrue);
+        expect(buttonNode2.hasFocus, isFalse);
+        primaryFocus!.nextFocus();
+        await tester.pump();
+        expect(buttonNode1.hasFocus, isTrue);
+        expect(buttonNode2.hasFocus, isFalse);
+      },
+    );
   });
 
   group('Diagnostics', () {
diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart
index d8bcfd5..052bd88 100644
--- a/packages/flutter/test/widgets/focus_manager_test.dart
+++ b/packages/flutter/test/widgets/focus_manager_test.dart
@@ -151,6 +151,39 @@
       expect(scope.traversalDescendants.contains(child2), isFalse);
     });
 
+    testWidgets('descendantsAreTraversable disables traversal for descendants.', (WidgetTester tester) async {
+      final BuildContext context = await setupWidget(tester);
+      final FocusScopeNode scope = FocusScopeNode(debugLabel: 'Scope');
+      final FocusAttachment scopeAttachment = scope.attach(context);
+      final FocusNode parent1 = FocusNode(debugLabel: 'Parent 1');
+      final FocusAttachment parent1Attachment = parent1.attach(context);
+      final FocusNode parent2 = FocusNode(debugLabel: 'Parent 2');
+      final FocusAttachment parent2Attachment = parent2.attach(context);
+      final FocusNode child1 = FocusNode(debugLabel: 'Child 1');
+      final FocusAttachment child1Attachment = child1.attach(context);
+      final FocusNode child2 = FocusNode(debugLabel: 'Child 2');
+      final FocusAttachment child2Attachment = child2.attach(context);
+
+      scopeAttachment.reparent(parent: tester.binding.focusManager.rootScope);
+      parent1Attachment.reparent(parent: scope);
+      parent2Attachment.reparent(parent: scope);
+      child1Attachment.reparent(parent: parent1);
+      child2Attachment.reparent(parent: parent2);
+
+      expect(scope.traversalDescendants, equals(<FocusNode>[child1, parent1, child2, parent2]));
+
+      parent2.descendantsAreTraversable = false;
+      expect(scope.traversalDescendants, equals(<FocusNode>[child1, parent1, parent2]));
+
+      parent1.descendantsAreTraversable = false;
+      expect(scope.traversalDescendants, equals(<FocusNode>[parent1, parent2]));
+
+      parent1.descendantsAreTraversable = true;
+      parent2.descendantsAreTraversable = true;
+      scope.descendantsAreTraversable = false;
+      expect(scope.traversalDescendants, equals(<FocusNode>[]));
+    });
+
     testWidgets("canRequestFocus doesn't affect traversalChildren", (WidgetTester tester) async {
       final BuildContext context = await setupWidget(tester);
       final FocusScopeNode scope = FocusScopeNode(debugLabel: 'Scope');
@@ -191,6 +224,7 @@
       expect(description, <String>[
         'context: null',
         'descendantsAreFocusable: true',
+        'descendantsAreTraversable: true',
         'canRequestFocus: true',
         'hasFocus: false',
         'hasPrimaryFocus: false',
@@ -1156,6 +1190,7 @@
       expect(description, <String>[
         'context: null',
         'descendantsAreFocusable: true',
+        'descendantsAreTraversable: true',
         'canRequestFocus: true',
         'hasFocus: false',
         'hasPrimaryFocus: false',
diff --git a/packages/flutter/test/widgets/focus_scope_test.dart b/packages/flutter/test/widgets/focus_scope_test.dart
index 0e92cc2..8920314 100644
--- a/packages/flutter/test/widgets/focus_scope_test.dart
+++ b/packages/flutter/test/widgets/focus_scope_test.dart
@@ -1085,6 +1085,7 @@
       focusScopeNode.onKey = ignoreCallback;
       focusScopeNode.onKeyEvent = ignoreEventCallback;
       focusScopeNode.descendantsAreFocusable = false;
+      focusScopeNode.descendantsAreTraversable = false;
       focusScopeNode.skipTraversal = false;
       focusScopeNode.canRequestFocus = true;
       FocusScope focusScopeWidget = FocusScope.withExternalFocusNode(
@@ -1095,11 +1096,13 @@
       expect(focusScopeNode.onKey, equals(ignoreCallback));
       expect(focusScopeNode.onKeyEvent, equals(ignoreEventCallback));
       expect(focusScopeNode.descendantsAreFocusable, isFalse);
+      expect(focusScopeNode.descendantsAreTraversable, isFalse);
       expect(focusScopeNode.skipTraversal, isFalse);
       expect(focusScopeNode.canRequestFocus, isTrue);
       expect(focusScopeWidget.onKey, equals(focusScopeNode.onKey));
       expect(focusScopeWidget.onKeyEvent, equals(focusScopeNode.onKeyEvent));
       expect(focusScopeWidget.descendantsAreFocusable, equals(focusScopeNode.descendantsAreFocusable));
+      expect(focusScopeWidget.descendantsAreTraversable, equals(focusScopeNode.descendantsAreTraversable));
       expect(focusScopeWidget.skipTraversal, equals(focusScopeNode.skipTraversal));
       expect(focusScopeWidget.canRequestFocus, equals(focusScopeNode.canRequestFocus));
 
@@ -1111,6 +1114,7 @@
       focusScopeNode.onKey = handleCallback;
       focusScopeNode.onKeyEvent = handleEventCallback;
       focusScopeNode.descendantsAreFocusable = true;
+      focusScopeNode.descendantsAreTraversable = true;
       focusScopeWidget = FocusScope.withExternalFocusNode(
         focusScopeNode: focusScopeNode,
         child: Container(key: key1),
@@ -1119,11 +1123,13 @@
       expect(focusScopeNode.onKey, equals(handleCallback));
       expect(focusScopeNode.onKeyEvent, equals(handleEventCallback));
       expect(focusScopeNode.descendantsAreFocusable, isTrue);
+      expect(focusScopeNode.descendantsAreTraversable, isTrue);
       expect(focusScopeNode.skipTraversal, isFalse);
       expect(focusScopeNode.canRequestFocus, isTrue);
       expect(focusScopeWidget.onKey, equals(focusScopeNode.onKey));
       expect(focusScopeWidget.onKeyEvent, equals(focusScopeNode.onKeyEvent));
       expect(focusScopeWidget.descendantsAreFocusable, equals(focusScopeNode.descendantsAreFocusable));
+      expect(focusScopeWidget.descendantsAreTraversable, equals(focusScopeNode.descendantsAreTraversable));
       expect(focusScopeWidget.skipTraversal, equals(focusScopeNode.skipTraversal));
       expect(focusScopeWidget.canRequestFocus, equals(focusScopeNode.canRequestFocus));
 
@@ -1639,12 +1645,47 @@
       expect(containerNode.hasFocus, isFalse);
       expect(unfocusableNode.hasFocus, isFalse);
     });
+
+    testWidgets('descendantsAreTraversable works as expected.', (WidgetTester tester) async {
+      final FocusScopeNode scopeNode = FocusScopeNode(debugLabel: 'scope');
+      final FocusNode node1 = FocusNode(debugLabel: 'node 1');
+      final FocusNode node2 = FocusNode(debugLabel: 'node 2');
+      final FocusNode node3 = FocusNode(debugLabel: 'node 3');
+
+      await tester.pumpWidget(
+        FocusScope(
+          node: scopeNode,
+          child: Column(
+            children: <Widget>[
+              Focus(
+                focusNode: node1,
+                child: Container(),
+              ),
+              Focus(
+                focusNode: node2,
+                descendantsAreTraversable: false,
+                child: Focus(
+                  focusNode: node3,
+                  child: Container(),
+                )
+              ),
+            ],
+          ),
+        ),
+      );
+      await tester.pump();
+
+      expect(scopeNode.traversalDescendants, equals(<FocusNode>[node1, node2]));
+      expect(node2.traversalDescendants, equals(<FocusNode>[]));
+    });
+
     testWidgets("Focus doesn't introduce a Semantics node when includeSemantics is false", (WidgetTester tester) async {
       final SemanticsTester semantics = SemanticsTester(tester);
       await tester.pumpWidget(Focus(includeSemantics: false, child: Container()));
       final TestSemantics expectedSemantics = TestSemantics.root();
       expect(semantics, hasSemantics(expectedSemantics));
     });
+
     testWidgets('Focus updates the onKey handler when the widget updates', (WidgetTester tester) async {
       final GlobalKey key1 = GlobalKey(debugLabel: '1');
       final FocusNode focusNode = FocusNode();
@@ -1695,6 +1736,7 @@
       await tester.sendKeyEvent(LogicalKeyboardKey.enter);
       expect(keyEventHandled, isTrue);
     });
+
     testWidgets('Focus updates the onKeyEvent handler when the widget updates', (WidgetTester tester) async {
       final GlobalKey key1 = GlobalKey(debugLabel: '1');
       final FocusNode focusNode = FocusNode();
@@ -1745,6 +1787,7 @@
       await tester.sendKeyEvent(LogicalKeyboardKey.enter);
       expect(keyEventHandled, isTrue);
     });
+
     testWidgets("Focus doesn't update the focusNode attributes when the widget updates if withExternalFocusNode is used", (WidgetTester tester) async {
       final GlobalKey key1 = GlobalKey(debugLabel: '1');
       final FocusNode focusNode = FocusNode();
@@ -1766,6 +1809,7 @@
       focusNode.onKey = ignoreCallback;
       focusNode.onKeyEvent = ignoreEventCallback;
       focusNode.descendantsAreFocusable = false;
+      focusNode.descendantsAreTraversable = false;
       focusNode.skipTraversal = false;
       focusNode.canRequestFocus = true;
       Focus focusWidget = Focus.withExternalFocusNode(
@@ -1776,11 +1820,13 @@
       expect(focusNode.onKey, equals(ignoreCallback));
       expect(focusNode.onKeyEvent, equals(ignoreEventCallback));
       expect(focusNode.descendantsAreFocusable, isFalse);
+      expect(focusNode.descendantsAreTraversable, isFalse);
       expect(focusNode.skipTraversal, isFalse);
       expect(focusNode.canRequestFocus, isTrue);
       expect(focusWidget.onKey, equals(focusNode.onKey));
       expect(focusWidget.onKeyEvent, equals(focusNode.onKeyEvent));
       expect(focusWidget.descendantsAreFocusable, equals(focusNode.descendantsAreFocusable));
+      expect(focusWidget.descendantsAreTraversable, equals(focusNode.descendantsAreTraversable));
       expect(focusWidget.skipTraversal, equals(focusNode.skipTraversal));
       expect(focusWidget.canRequestFocus, equals(focusNode.canRequestFocus));
 
@@ -1792,6 +1838,7 @@
       focusNode.onKey = handleCallback;
       focusNode.onKeyEvent = handleEventCallback;
       focusNode.descendantsAreFocusable = true;
+      focusNode.descendantsAreTraversable = true;
       focusWidget = Focus.withExternalFocusNode(
         focusNode: focusNode,
         child: Container(key: key1),
@@ -1800,18 +1847,29 @@
       expect(focusNode.onKey, equals(handleCallback));
       expect(focusNode.onKeyEvent, equals(handleEventCallback));
       expect(focusNode.descendantsAreFocusable, isTrue);
+      expect(focusNode.descendantsAreTraversable, isTrue);
       expect(focusNode.skipTraversal, isFalse);
       expect(focusNode.canRequestFocus, isTrue);
       expect(focusWidget.onKey, equals(focusNode.onKey));
       expect(focusWidget.onKeyEvent, equals(focusNode.onKeyEvent));
       expect(focusWidget.descendantsAreFocusable, equals(focusNode.descendantsAreFocusable));
+      expect(focusWidget.descendantsAreTraversable, equals(focusNode.descendantsAreTraversable));
       expect(focusWidget.skipTraversal, equals(focusNode.skipTraversal));
       expect(focusWidget.canRequestFocus, equals(focusNode.canRequestFocus));
 
       await tester.sendKeyEvent(LogicalKeyboardKey.enter);
       expect(keyEventHandled, isTrue);
     });
+
+    testWidgets('Focus passes changes in attribute values to its focus node', (WidgetTester tester) async {
+      await tester.pumpWidget(
+        Focus(
+          child: Container(),
+        ),
+      );
+    });
   });
+
   group('ExcludeFocus', () {
     testWidgets("Descendants of ExcludeFocus aren't focusable.", (WidgetTester tester) async {
       final GlobalKey key1 = GlobalKey(debugLabel: '1');
@@ -1919,6 +1977,7 @@
       expect(parentFocusNode.hasFocus, isFalse);
       expect(parentFocusNode.enclosingScope!.hasPrimaryFocus, isTrue);
     });
+
     testWidgets("ExcludeFocus doesn't introduce a Semantics node", (WidgetTester tester) async {
       final SemanticsTester semantics = SemanticsTester(tester);
       await tester.pumpWidget(ExcludeFocus(child: Container()));
diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart
index af77ebd..ff4c6e8 100644
--- a/packages/flutter/test/widgets/focus_traversal_test.dart
+++ b/packages/flutter/test/widgets/focus_traversal_test.dart
@@ -2054,6 +2054,7 @@
       final TestSemantics expectedSemantics = TestSemantics.root();
       expect(semantics, hasSemantics(expectedSemantics));
     });
+
     testWidgets("Descendants of FocusTraversalGroup aren't focusable if descendantsAreFocusable is false.", (WidgetTester tester) async {
       final GlobalKey key1 = GlobalKey(debugLabel: '1');
       final GlobalKey key2 = GlobalKey(debugLabel: '2');
@@ -2092,6 +2093,78 @@
       expect(containerNode.hasFocus, isFalse);
       expect(unfocusableNode.hasFocus, isFalse);
     });
+
+    testWidgets("Descendants of FocusTraversalGroup aren't traversable if descendantsAreTraversable is false.", (WidgetTester tester) async {
+      final FocusNode node1 = FocusNode();
+      final FocusNode node2 = FocusNode();
+
+      await tester.pumpWidget(
+        FocusTraversalGroup(
+          descendantsAreTraversable: false,
+          child: Column(
+            children: <Widget>[
+              Focus(
+                focusNode: node1,
+                child: Container(),
+              ),
+              Focus(
+                focusNode: node2,
+                child: Container(),
+              ),
+            ],
+          ),
+        ),
+      );
+
+      node1.requestFocus();
+      await tester.pump();
+
+      expect(node1.hasPrimaryFocus, isTrue);
+      expect(node2.hasPrimaryFocus, isFalse);
+
+      expect(primaryFocus!.nextFocus(), isFalse);
+      await tester.pump();
+
+      expect(node1.hasPrimaryFocus, isTrue);
+      expect(node2.hasPrimaryFocus, isFalse);
+    });
+
+    testWidgets("FocusTraversalGroup with skipTraversal for all descendents set to true doesn't cause an exception.", (WidgetTester tester) async {
+      final FocusNode node1 = FocusNode();
+      final FocusNode node2 = FocusNode();
+
+      await tester.pumpWidget(
+        FocusTraversalGroup(
+          child: Column(
+            children: <Widget>[
+              Focus(
+                skipTraversal: true,
+                focusNode: node1,
+                child: Container(),
+              ),
+              Focus(
+                skipTraversal: true,
+                focusNode: node2,
+                child: Container(),
+              ),
+            ],
+          ),
+        ),
+      );
+
+      node1.requestFocus();
+      await tester.pump();
+
+      expect(node1.hasPrimaryFocus, isTrue);
+      expect(node2.hasPrimaryFocus, isFalse);
+
+      expect(primaryFocus!.nextFocus(), isFalse);
+      await tester.pump();
+
+      expect(node1.hasPrimaryFocus, isTrue);
+      expect(node2.hasPrimaryFocus, isFalse);
+    });
+
     testWidgets("Nested FocusTraversalGroup with unfocusable children doesn't assert.", (WidgetTester tester) async {
       final GlobalKey key1 = GlobalKey(debugLabel: '1');
       final GlobalKey key2 = GlobalKey(debugLabel: '2');
@@ -2140,6 +2213,7 @@
       expect(containerNode.hasFocus, isFalse);
       expect(unfocusableNode.hasFocus, isFalse);
     });
+
     testWidgets("Empty FocusTraversalGroup doesn't cause an exception.", (WidgetTester tester) async {
       final GlobalKey key = GlobalKey(debugLabel: 'Test Key');
       final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
@@ -2169,6 +2243,7 @@
       expect(primaryFocus, equals(focusNode));
     });
   });
+
   group(RawKeyboardListener, () {
     testWidgets('Raw keyboard listener introduces a Semantics node by default', (WidgetTester tester) async {
       final SemanticsTester semantics = SemanticsTester(tester);
@@ -2195,6 +2270,7 @@
         ignoreTransform: true,
       ));
     });
+
     testWidgets("Raw keyboard listener doesn't introduce a Semantics node when specified", (WidgetTester tester) async {
       final SemanticsTester semantics = SemanticsTester(tester);
       final FocusNode focusNode = FocusNode();
@@ -2209,6 +2285,63 @@
       expect(semantics, hasSemantics(expectedSemantics));
     });
   });
+
+  group(ExcludeFocusTraversal, () {
+    testWidgets("Descendants aren't traversable", (WidgetTester tester) async {
+      final FocusNode node1 = FocusNode(debugLabel: 'node 1');
+      final FocusNode node2 = FocusNode(debugLabel: 'node 2');
+      final FocusNode node3 = FocusNode(debugLabel: 'node 3');
+      final FocusNode node4 = FocusNode(debugLabel: 'node 4');
+
+      await tester.pumpWidget(
+        FocusTraversalGroup(
+          child: Column(
+            children: <Widget>[
+              Focus(
+                autofocus: true,
+                focusNode: node1,
+                child: Container(),
+              ),
+              ExcludeFocusTraversal(
+                child: Focus(
+                  focusNode: node2,
+                  child: Focus(
+                    focusNode: node3,
+                    child: Container(),
+                  ),
+                ),
+              ),
+              Focus(
+                focusNode: node4,
+                child: Container(),
+              ),
+            ],
+          ),
+        ),
+      );
+      await tester.pump();
+
+      expect(node1.hasPrimaryFocus, isTrue);
+      expect(node2.hasPrimaryFocus, isFalse);
+      expect(node3.hasPrimaryFocus, isFalse);
+      expect(node4.hasPrimaryFocus, isFalse);
+
+      node1.nextFocus();
+      await tester.pump();
+
+      expect(node1.hasPrimaryFocus, isFalse);
+      expect(node2.hasPrimaryFocus, isFalse);
+      expect(node3.hasPrimaryFocus, isFalse);
+      expect(node4.hasPrimaryFocus, isTrue);
+    });
+
+    testWidgets("Doesn't introduce a Semantics node", (WidgetTester tester) async {
+      final SemanticsTester semantics = SemanticsTester(tester);
+      await tester.pumpWidget(ExcludeFocusTraversal(child: Container()));
+      final TestSemantics expectedSemantics = TestSemantics.root();
+      expect(semantics, hasSemantics(expectedSemantics));
+    });
+  });
 }
 
 class TestRoute extends PageRouteBuilder<void> {