Skip to content

Commit 0f3bd90

Browse files
authored
Adds a parent scope TraversalEdgeBehavior and fixes modal route to no… (#130841)
…t trap the focus fixes flutter/flutter#112567 Several things I done: 1. add new enum `parentScope` 2. refactor _sortAllDescendants so that it doesn't recursively looking into its descendant when it encounter a FocusScopeNode. 3. When the nextFocus try to focus into a FocusScopeNode, it will try to find the first focusable FocusNode in traversal order and focus it instead if it doesn't have a first focus. 4. Change the default in Navigator to always be `parentScope` 5. Only the root navigator will use `leaveFlutterView` if platform is web. Previously 2 and 3 are not needed because once a focusscope trapped the focus, there isn't a chance where the parent focusscope have to deal with next focus. If we don't do 2 and 3 after the change, it will cause it to stuck in the current scope again. Because once the focus leave the current scope, it needs to also remove the first focus in that scope (so that it can start fresh when focus traversal back to the scope in the future). At this point the current scope will have the primary focus without the first focus. The parent scope will then try to find the next focus, and it will place the focus to the first traversal child in the current scope again. ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat
1 parent 1b1c8a1 commit 0f3bd90

File tree

8 files changed

+250
-35
lines changed

8 files changed

+250
-35
lines changed

packages/flutter/lib/src/widgets/app.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,6 +1685,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
16851685
},
16861686
onUnknownRoute: _onUnknownRoute,
16871687
observers: widget.navigatorObservers!,
1688+
routeTraversalEdgeBehavior: kIsWeb ? TraversalEdgeBehavior.leaveFlutterView : TraversalEdgeBehavior.parentScope,
16881689
reportsRouteUpdateToEngine: true,
16891690
),
16901691
);

packages/flutter/lib/src/widgets/focus_traversal.dart

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ enum TraversalEdgeBehavior {
120120
/// address bar, escape an `iframe`, or focus on HTML elements other than
121121
/// those managed by Flutter.
122122
leaveFlutterView,
123+
124+
/// Allows focus to traverse up to parent scope.
125+
///
126+
/// When reaching the edge of the current scope, requesting the next focus
127+
/// will look up to the parent scope of the current scope and focus the focus
128+
/// node next to the current scope.
129+
///
130+
/// If there is no parent scope above the current scope, fallback to
131+
/// [closedLoop] behavior.
132+
parentScope,
123133
}
124134

125135
/// Determines how focusable widgets are traversed within a [FocusTraversalGroup].
@@ -186,6 +196,60 @@ abstract class FocusTraversalPolicy with Diagnosticable {
186196
);
187197
}
188198

199+
/// Request focus on a focus node as a result of a tab traversal.
200+
///
201+
/// If the `node` is a [FocusScopeNode], this method will recursively find
202+
/// the next focus from its descendants until it find a regular [FocusNode].
203+
///
204+
/// Returns true if this method focused a new focus node.
205+
bool _requestTabTraversalFocus(
206+
FocusNode node, {
207+
ScrollPositionAlignmentPolicy? alignmentPolicy,
208+
double? alignment,
209+
Duration? duration,
210+
Curve? curve,
211+
required bool forward,
212+
}) {
213+
if (node is FocusScopeNode) {
214+
if (node.focusedChild != null) {
215+
// Can't stop here as the `focusedChild` may be a focus scope node
216+
// without a first focus. The first focus will be picked in the
217+
// next iteration.
218+
return _requestTabTraversalFocus(
219+
node.focusedChild!,
220+
alignmentPolicy: alignmentPolicy,
221+
alignment: alignment,
222+
duration: duration,
223+
curve: curve,
224+
forward: forward,
225+
);
226+
}
227+
final List<FocusNode> sortedChildren = _sortAllDescendants(node, node);
228+
if (sortedChildren.isNotEmpty) {
229+
_requestTabTraversalFocus(
230+
forward ? sortedChildren.first : sortedChildren.last,
231+
alignmentPolicy: alignmentPolicy,
232+
alignment: alignment,
233+
duration: duration,
234+
curve: curve,
235+
forward: forward,
236+
);
237+
// Regardless if _requestTabTraversalFocus return true or false, a first
238+
// focus has been picked.
239+
return true;
240+
}
241+
}
242+
final bool nodeHadPrimaryFocus = node.hasPrimaryFocus;
243+
requestFocusCallback(
244+
node,
245+
alignmentPolicy: alignmentPolicy,
246+
alignment: alignment,
247+
duration: duration,
248+
curve: curve,
249+
);
250+
return !nodeHadPrimaryFocus;
251+
}
252+
189253
/// Returns the node that should receive focus if focus is traversing
190254
/// forwards, and there is no current focus.
191255
///
@@ -352,10 +416,21 @@ abstract class FocusTraversalPolicy with Diagnosticable {
352416
@protected
353417
Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode);
354418

355-
Map<FocusNode?, _FocusTraversalGroupInfo> _findGroups(FocusScopeNode scope, _FocusTraversalGroupNode? scopeGroupNode, FocusNode currentNode) {
419+
static Iterable<FocusNode> _getDescendantsWithoutExpandingScope(FocusNode node) {
420+
final List<FocusNode> result = <FocusNode>[];
421+
for (final FocusNode child in node.children) {
422+
if (child is! FocusScopeNode) {
423+
result.addAll(_getDescendantsWithoutExpandingScope(child));
424+
}
425+
result.add(child);
426+
}
427+
return result;
428+
}
429+
430+
static Map<FocusNode?, _FocusTraversalGroupInfo> _findGroups(FocusScopeNode scope, _FocusTraversalGroupNode? scopeGroupNode, FocusNode currentNode) {
356431
final FocusTraversalPolicy defaultPolicy = scopeGroupNode?.policy ?? ReadingOrderTraversalPolicy();
357432
final Map<FocusNode?, _FocusTraversalGroupInfo> groups = <FocusNode?, _FocusTraversalGroupInfo>{};
358-
for (final FocusNode node in scope.descendants) {
433+
for (final FocusNode node in _getDescendantsWithoutExpandingScope(scope)) {
359434
final _FocusTraversalGroupNode? groupNode = FocusTraversalGroup._getGroupNode(node);
360435
// Group nodes need to be added to their parent's node, or to the "null"
361436
// node if no parent is found. This creates the hierarchy of group nodes
@@ -388,7 +463,7 @@ abstract class FocusTraversalPolicy with Diagnosticable {
388463

389464
// Sort all descendants, taking into account the FocusTraversalGroup
390465
// that they are each in, and filtering out non-traversable/focusable nodes.
391-
List<FocusNode> _sortAllDescendants(FocusScopeNode scope, FocusNode currentNode) {
466+
static List<FocusNode> _sortAllDescendants(FocusScopeNode scope, FocusNode currentNode) {
392467
final _FocusTraversalGroupNode? scopeGroupNode = FocusTraversalGroup._getGroupNode(scope);
393468
// Build the sorting data structure, separating descendants into groups.
394469
final Map<FocusNode?, _FocusTraversalGroupInfo> groups = _findGroups(scope, scopeGroupNode, currentNode);
@@ -473,52 +548,81 @@ abstract class FocusTraversalPolicy with Diagnosticable {
473548
if (focusedChild == null) {
474549
final FocusNode? firstFocus = forward ? findFirstFocus(currentNode) : findLastFocus(currentNode);
475550
if (firstFocus != null) {
476-
requestFocusCallback(
551+
return _requestTabTraversalFocus(
477552
firstFocus,
478553
alignmentPolicy: forward ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
554+
forward: forward,
479555
);
480-
return true;
481556
}
482557
}
483558
focusedChild ??= nearestScope;
484559
final List<FocusNode> sortedNodes = _sortAllDescendants(nearestScope, focusedChild);
485-
486560
assert(sortedNodes.contains(focusedChild));
487-
if (sortedNodes.length < 2) {
488-
// If there are no nodes to traverse to, like when descendantsAreTraversable
489-
// is false or skipTraversal for all the nodes is true.
490-
return false;
491-
}
561+
492562
if (forward && focusedChild == sortedNodes.last) {
493563
switch (nearestScope.traversalEdgeBehavior) {
494564
case TraversalEdgeBehavior.leaveFlutterView:
495565
focusedChild.unfocus();
496566
return false;
567+
case TraversalEdgeBehavior.parentScope:
568+
final FocusScopeNode? parentScope = nearestScope.enclosingScope;
569+
if (parentScope != null && parentScope != FocusManager.instance.rootScope) {
570+
focusedChild.unfocus();
571+
parentScope.nextFocus();
572+
// Verify the focus really has changed.
573+
return focusedChild.enclosingScope?.focusedChild != focusedChild;
574+
}
575+
// No valid parent scope. Fallback to closed loop behavior.
576+
return _requestTabTraversalFocus(
577+
sortedNodes.first,
578+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
579+
forward: forward,
580+
);
497581
case TraversalEdgeBehavior.closedLoop:
498-
requestFocusCallback(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
499-
return true;
582+
return _requestTabTraversalFocus(
583+
sortedNodes.first,
584+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
585+
forward: forward,
586+
);
500587
}
501588
}
502589
if (!forward && focusedChild == sortedNodes.first) {
503590
switch (nearestScope.traversalEdgeBehavior) {
504591
case TraversalEdgeBehavior.leaveFlutterView:
505592
focusedChild.unfocus();
506593
return false;
594+
case TraversalEdgeBehavior.parentScope:
595+
final FocusScopeNode? parentScope = nearestScope.enclosingScope;
596+
if (parentScope != null && parentScope != FocusManager.instance.rootScope) {
597+
focusedChild.unfocus();
598+
parentScope.previousFocus();
599+
// Verify the focus really has changed.
600+
return focusedChild.enclosingScope?.focusedChild != focusedChild;
601+
}
602+
// No valid parent scope. Fallback to closed loop behavior.
603+
return _requestTabTraversalFocus(
604+
sortedNodes.last,
605+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
606+
forward: forward,
607+
);
507608
case TraversalEdgeBehavior.closedLoop:
508-
requestFocusCallback(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
509-
return true;
609+
return _requestTabTraversalFocus(
610+
sortedNodes.last,
611+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
612+
forward: forward,
613+
);
510614
}
511615
}
512616

513617
final Iterable<FocusNode> maybeFlipped = forward ? sortedNodes : sortedNodes.reversed;
514618
FocusNode? previousNode;
515619
for (final FocusNode node in maybeFlipped) {
516620
if (previousNode == focusedChild) {
517-
requestFocusCallback(
621+
return _requestTabTraversalFocus(
518622
node,
519623
alignmentPolicy: forward ? ScrollPositionAlignmentPolicy.keepVisibleAtEnd : ScrollPositionAlignmentPolicy.keepVisibleAtStart,
624+
forward: forward,
520625
);
521-
return true;
522626
}
523627
previousNode = node;
524628
}

packages/flutter/lib/src/widgets/navigator.dart

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,9 +1146,7 @@ class DefaultTransitionDelegate<T> extends TransitionDelegate<T> {
11461146
/// The default value of [Navigator.routeTraversalEdgeBehavior].
11471147
///
11481148
/// {@macro flutter.widgets.navigator.routeTraversalEdgeBehavior}
1149-
const TraversalEdgeBehavior kDefaultRouteTraversalEdgeBehavior = kIsWeb
1150-
? TraversalEdgeBehavior.leaveFlutterView
1151-
: TraversalEdgeBehavior.closedLoop;
1149+
const TraversalEdgeBehavior kDefaultRouteTraversalEdgeBehavior = TraversalEdgeBehavior.parentScope;
11521150

11531151
/// A widget that manages a set of child widgets with a stack discipline.
11541152
///

packages/flutter/lib/src/widgets/routes.dart

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,9 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
834834
late Listenable _listenable;
835835

836836
/// The node this scope will use for its root [FocusScope] widget.
837-
final FocusScopeNode focusScopeNode = FocusScopeNode(debugLabel: '$_ModalScopeState Focus Scope');
837+
final FocusScopeNode focusScopeNode = FocusScopeNode(
838+
debugLabel: '$_ModalScopeState Focus Scope',
839+
);
838840
final ScrollController primaryScrollController = ScrollController();
839841

840842
@override
@@ -936,6 +938,8 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
936938
controller: primaryScrollController,
937939
child: FocusScope(
938940
node: focusScopeNode, // immutable
941+
// Only top most route can participate in focus traversal.
942+
skipTraversal: !widget.route.isCurrent,
939943
child: RepaintBoundary(
940944
child: AnimatedBuilder(
941945
animation: _listenable, // immutable
@@ -1704,11 +1708,26 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
17041708
changedInternalState();
17051709
}
17061710

1711+
@override
1712+
void didChangeNext(Route<dynamic>? nextRoute) {
1713+
super.didChangeNext(nextRoute);
1714+
changedInternalState();
1715+
}
1716+
1717+
@override
1718+
void didPopNext(Route<dynamic> nextRoute) {
1719+
super.didPopNext(nextRoute);
1720+
changedInternalState();
1721+
}
1722+
17071723
@override
17081724
void changedInternalState() {
17091725
super.changedInternalState();
1710-
setState(() { /* internal state already changed */ });
1711-
_modalBarrier.markNeedsBuild();
1726+
// No need to mark dirty if this method is called during build phase.
1727+
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks) {
1728+
setState(() { /* internal state already changed */ });
1729+
_modalBarrier.markNeedsBuild();
1730+
}
17121731
_modalScope.maintainState = maintainState;
17131732
}
17141733

packages/flutter/test/material/icon_button_test.dart

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:ui';
66

7+
import 'package:flutter/foundation.dart';
78
import 'package:flutter/material.dart';
89
import 'package:flutter/rendering.dart';
910
import 'package:flutter_test/flutter_test.dart';
@@ -792,6 +793,10 @@ void main() {
792793
testWidgetsWithLeakTracking("Disabled IconButton can't be traversed to when disabled.", (WidgetTester tester) async {
793794
final FocusNode focusNode1 = FocusNode(debugLabel: 'IconButton 1');
794795
final FocusNode focusNode2 = FocusNode(debugLabel: 'IconButton 2');
796+
addTearDown(() {
797+
focusNode1.dispose();
798+
focusNode2.dispose();
799+
});
795800

796801
await tester.pumpWidget(
797802
wrap(
@@ -821,11 +826,8 @@ void main() {
821826
expect(focusNode1.nextFocus(), isFalse);
822827
await tester.pump();
823828

824-
expect(focusNode1.hasPrimaryFocus, isTrue);
829+
expect(focusNode1.hasPrimaryFocus, !kIsWeb);
825830
expect(focusNode2.hasPrimaryFocus, isFalse);
826-
827-
focusNode1.dispose();
828-
focusNode2.dispose();
829831
});
830832

831833
group('feedback', () {

packages/flutter/test/widgets/actions_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ void main() {
965965
expect(buttonNode2.hasFocus, isFalse);
966966
primaryFocus!.nextFocus();
967967
await tester.pump();
968-
expect(buttonNode1.hasFocus, isTrue);
968+
expect(buttonNode1.hasFocus, isFalse);
969969
expect(buttonNode2.hasFocus, isFalse);
970970
},
971971
);

0 commit comments

Comments
 (0)